Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44d6038c8b | ||
|
|
c2826d3ab6 | ||
|
|
7c943f4831 | ||
|
|
5004f5213c | ||
|
|
9b7f7f60d2 | ||
|
|
cff6308018 | ||
|
|
e7bf23e276 | ||
|
|
35110c96ce | ||
|
|
dcc55a158e | ||
|
|
dd08c035df | ||
|
|
b6f0391cb3 | ||
|
|
22f3d8c2c5 | ||
|
|
4c2a6dc87b | ||
|
|
33639c0a7c | ||
|
|
a15e696059 | ||
|
|
b8e797f419 | ||
|
|
72b2932197 | ||
|
|
c4a69d30f7 | ||
|
|
fe0c7d83a9 | ||
|
|
f92f9bcdad | ||
|
|
53d5e2c5aa | ||
|
|
6888965f5a | ||
|
|
f1ba21e7fa | ||
|
|
8abc44bbdf | ||
|
|
a6e3a35f04 | ||
|
|
a3de9f3d93 | ||
|
|
c823243e53 |
@@ -26,14 +26,14 @@ Which of these ran green, and anything you exercised by hand. On-device notes
|
|||||||
are especially useful for UI changes, and for anything touching the provider
|
are especially useful for UI changes, and for anything touching the provider
|
||||||
read/write paths (OpenTasks / tasks.org installed).
|
read/write paths (OpenTasks / tasks.org installed).
|
||||||
|
|
||||||
./gradlew lintDebug :app:testDebugUnitTest :app:assembleDebug
|
./gradlew lintFullDebug lintOfflineDebug :app:testFullDebugUnitTest :app:testOfflineDebugUnitTest :app:assembleDebug
|
||||||
python3 scripts/check_translations.py
|
python3 scripts/check_translations.py
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
|
||||||
### Checklist
|
### Checklist
|
||||||
|
|
||||||
- [ ] `./gradlew lintDebug :app:testDebugUnitTest :app:assembleDebug` passes locally
|
- [ ] `./gradlew lintFullDebug lintOfflineDebug :app:testFullDebugUnitTest :app:testOfflineDebugUnitTest :app:assembleDebug` passes locally
|
||||||
- [ ] New domain logic comes with JVM unit tests under `app/src/test/`
|
- [ ] New domain logic comes with JVM unit tests under `app/src/test/`
|
||||||
- [ ] Provider details stay inside `data/tasks/`
|
- [ ] Provider details stay inside `data/tasks/`
|
||||||
- [ ] No `values-*/strings.xml` touched (Weblate owns those; new English strings in `values/` are fine)
|
- [ ] No `values-*/strings.xml` touched (Weblate owns those; new English strings in `values/` are fine)
|
||||||
|
|||||||
@@ -37,11 +37,29 @@ jobs:
|
|||||||
- name: Reproducible-release invariant
|
- name: Reproducible-release invariant
|
||||||
run: bash scripts/check_reproducible_release.sh
|
run: bash scripts/check_reproducible_release.sh
|
||||||
|
|
||||||
|
# The committed versionName must parse (X.Y.Z or X.Y.Z-beta.N) and the
|
||||||
|
# committed versionCode must be the one it derives: the official F-Droid
|
||||||
|
# repo builds the tag as committed. And a beta must never reach main,
|
||||||
|
# where the release pipeline would ship it to F-Droid and Play; betas are
|
||||||
|
# cut from release/* branches (docs/RELEASING.md).
|
||||||
|
- name: Committed version is well-formed
|
||||||
|
env:
|
||||||
|
BASE: ${{ github.base_ref }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
bash scripts/version_info.sh --check
|
||||||
|
if [ "${BASE#refs/heads/}" = "main" ] && [ "$(bash scripts/version_info.sh channel)" = "beta" ]; then
|
||||||
|
echo "ERROR: versionName $(bash scripts/version_info.sh version) is a beta." >&2
|
||||||
|
echo "Set the stable version (and its versionCode) before merging into main." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Also cheap, also always-on. Two failures in one: the script exits
|
# Also cheap, also always-on. Two failures in one: the script exits
|
||||||
# non-zero if this version's changelog is over the 500-character limit,
|
# non-zero if this version's changelog is over the 500-character limit,
|
||||||
# and the porcelain check below catches a version with no committed
|
# and the porcelain check below catches a version with no committed
|
||||||
# changelog, which would otherwise ship the CHANGELOG.md section instead
|
# changelog, which would otherwise ship the CHANGELOG.md section instead
|
||||||
# of a hand-written summary.
|
# of a hand-written summary. A beta version passes both: it ships no
|
||||||
|
# What's New, so the script writes nothing for it.
|
||||||
- name: Changelog fits the stores, and is committed
|
- name: Changelog fits the stores, and is committed
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
@@ -109,10 +127,12 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
if: steps.scope.outputs.code == 'true'
|
if: steps.scope.outputs.code == 'true'
|
||||||
|
# JetBrains 21 is what gradle-daemon-jvm.properties asks for; installing it
|
||||||
|
# here keeps Gradle from downloading it via foojay.
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: 'zulu'
|
distribution: 'jetbrains'
|
||||||
java-version: '17'
|
java-version: '21'
|
||||||
|
|
||||||
# Fully qualified on purpose. Codeberg resolves bare `uses:` refs against
|
# Fully qualified on purpose. Codeberg resolves bare `uses:` refs against
|
||||||
# data.forgejo.org, Forgejo's own action mirror — actions/checkout,
|
# data.forgejo.org, Forgejo's own action mirror — actions/checkout,
|
||||||
@@ -160,18 +180,18 @@ jobs:
|
|||||||
|
|
||||||
# No --no-daemon: the daemon lives only as long as this job container
|
# No --no-daemon: the daemon lives only as long as this job container
|
||||||
# and lets the following steps skip JVM startup + reconfiguration.
|
# and lets the following steps skip JVM startup + reconfiguration.
|
||||||
- name: Lint (debug variant only)
|
- name: Lint (debug variants only)
|
||||||
if: steps.scope.outputs.code == 'true'
|
if: steps.scope.outputs.code == 'true'
|
||||||
run: ./gradlew lintDebug
|
run: ./gradlew lintFullDebug lintOfflineDebug
|
||||||
|
|
||||||
# :dav is a plain JVM module, so it has no testDebugUnitTest — naming only
|
# :dav is a plain JVM module, so it has no testDebugUnitTest — naming only
|
||||||
# that task would compile the vendored suite and run none of it, which is
|
# that task would compile the vendored suite and run none of it, which is
|
||||||
# the whole safety argument in dav/PROVENANCE.md.
|
# the whole safety argument in dav/PROVENANCE.md.
|
||||||
- name: Unit tests
|
- name: Unit tests
|
||||||
if: steps.scope.outputs.code == 'true'
|
if: steps.scope.outputs.code == 'true'
|
||||||
run: ./gradlew testDebugUnitTest :dav:test :caldav:test
|
run: ./gradlew testFullDebugUnitTest testOfflineDebugUnitTest :dav:test :caldav:test
|
||||||
|
|
||||||
- name: Assemble debug APK
|
- name: Assemble debug APKs
|
||||||
if: steps.scope.outputs.code == 'true'
|
if: steps.scope.outputs.code == 'true'
|
||||||
run: ./gradlew assembleDebug
|
run: ./gradlew assembleDebug
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: Translations
|
|||||||
|
|
||||||
# Fast, SDK-free parity check for translation resources, so Weblate PRs (which
|
# Fast, SDK-free parity check for translation resources, so Weblate PRs (which
|
||||||
# only touch values-*/strings.xml) get quick feedback without the full Android
|
# only touch values-*/strings.xml) get quick feedback without the full Android
|
||||||
# build. The deeper checks still run in CI via lintDebug (ExtraTranslation).
|
# build. The deeper checks still run in CI via lintFullDebug (ExtraTranslation).
|
||||||
#
|
#
|
||||||
# Runs on every PR (no path filter) so the required "Translations / check"
|
# Runs on every PR (no path filter) so the required "Translations / check"
|
||||||
# status is always reported — like the `ci` job. A path-filtered workflow is
|
# status is always reported — like the `ci` job. A path-filtered workflow is
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
name: Beta — Codeberg pre-release
|
||||||
|
|
||||||
|
# A beta is cut by pushing a release branch whose committed versionName is
|
||||||
|
# X.Y.Z-beta.N (see docs/RELEASING.md). Same model as release.yaml: the
|
||||||
|
# committed version is the trigger and the vX.Y.Z-beta.N tag is an output. If
|
||||||
|
# its Codeberg pre-release doesn't carry the APK yet, this runs the unit tests, builds and
|
||||||
|
# signs the APK with the app key, records a Gitea pre-release (with the R8
|
||||||
|
# mapping) and publishes a Codeberg pre-release with the APK + SHA-256.
|
||||||
|
#
|
||||||
|
# Deliberately nothing else. A beta never reaches the F-Droid repos or Play:
|
||||||
|
# Obtainium hides pre-releases unless a user opts in, the official F-Droid
|
||||||
|
# recipe only picks up `^v[0-9.]+$` tags, and Codeberg's "latest release"
|
||||||
|
# skips pre-releases. Pushes of a release branch carrying a stable version
|
||||||
|
# (the usual state) fall through `detect` and do nothing.
|
||||||
|
#
|
||||||
|
# Lives in .gitea/workflows next to release.yaml for the same reason: it needs
|
||||||
|
# the app signing key, which only the self-hosted Gitea instance holds.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['release/**']
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: beta
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
detect:
|
||||||
|
# Gitea only; see the same guard in release.yaml.
|
||||||
|
if: github.repository_owner == 'makiolaj'
|
||||||
|
runs-on: light
|
||||||
|
outputs:
|
||||||
|
is_beta: ${{ steps.v.outputs.is_beta }}
|
||||||
|
version: ${{ steps.v.outputs.version }}
|
||||||
|
version_code: ${{ steps.v.outputs.version_code }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Resolve version and whether it still needs publishing
|
||||||
|
id: v
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
INFO=$(bash scripts/version_info.sh)
|
||||||
|
echo "$INFO"
|
||||||
|
echo "$INFO" >> "$GITHUB_OUTPUT"
|
||||||
|
if [ "$(bash scripts/version_info.sh channel)" != beta ]; then
|
||||||
|
echo "Not a beta — nothing to do."
|
||||||
|
echo "is_beta=false" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
# Done only once its Codeberg pre-release carries the APK, so a failed
|
||||||
|
# publish is redone by the next push; refused unless newer than the
|
||||||
|
# latest stable release.
|
||||||
|
GATE=$(bash scripts/release_gate.sh)
|
||||||
|
echo "is_beta=${GATE#cut=}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
beta:
|
||||||
|
needs: detect
|
||||||
|
if: needs.detect.outputs.is_beta == 'true'
|
||||||
|
runs-on: gradle
|
||||||
|
env:
|
||||||
|
ANDROID_HOME: /opt/android-sdk
|
||||||
|
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||||
|
VERSION: ${{ needs.detect.outputs.version }}
|
||||||
|
VERSION_CODE: ${{ needs.detect.outputs.version_code }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Setup Java
|
||||||
|
# JetBrains 21 is what gradle-daemon-jvm.properties asks for; installing it
|
||||||
|
# here keeps Gradle from downloading it via foojay.
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: 'jetbrains'
|
||||||
|
java-version: '21'
|
||||||
|
|
||||||
|
- name: Setup Android SDK
|
||||||
|
uses: android-actions/setup-android@v3
|
||||||
|
with:
|
||||||
|
packages: ''
|
||||||
|
|
||||||
|
- name: Setup Android SDK cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: /opt/android-sdk
|
||||||
|
key: ${{ runner.os }}-android-sdk-37-36.0.0
|
||||||
|
|
||||||
|
- name: Install Android SDK packages
|
||||||
|
run: |
|
||||||
|
yes | sdkmanager --licenses >/dev/null || true
|
||||||
|
sdkmanager \
|
||||||
|
"platform-tools" \
|
||||||
|
"platforms;android-37.0" \
|
||||||
|
"build-tools;36.0.0"
|
||||||
|
|
||||||
|
- name: Setup Gradle cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.gradle/caches
|
||||||
|
~/.gradle/wrapper
|
||||||
|
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-gradle-
|
||||||
|
|
||||||
|
- name: Install jq
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
SUDO=""
|
||||||
|
if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
$SUDO apt-get update
|
||||||
|
$SUDO apt-get install -y jq
|
||||||
|
elif command -v apk >/dev/null 2>&1; then
|
||||||
|
$SUDO apk add --no-cache jq
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Grant execute permission for gradlew
|
||||||
|
run: chmod +x ./gradlew
|
||||||
|
|
||||||
|
- name: Pin versionCode to versionName
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
sed -i "s/versionCode = .*/versionCode = $VERSION_CODE/" app/build.gradle.kts
|
||||||
|
grep -E 'versionName|versionCode' app/build.gradle.kts
|
||||||
|
|
||||||
|
- name: Unit tests
|
||||||
|
run: ./gradlew testFullDebugUnitTest
|
||||||
|
|
||||||
|
# The real app key, same as a stable release: a beta has to update in
|
||||||
|
# place to the next beta and to the stable version.
|
||||||
|
- name: Setup Android Keystore
|
||||||
|
env:
|
||||||
|
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
|
||||||
|
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||||
|
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
||||||
|
run: |
|
||||||
|
mkdir -p app
|
||||||
|
echo "$KEYSTORE_BASE64" | base64 --decode > app/upload-keystore.jks
|
||||||
|
cat > key.properties <<EOF
|
||||||
|
storePassword=$KEY_PASSWORD
|
||||||
|
keyPassword=$KEY_PASSWORD
|
||||||
|
keyAlias=$KEY_ALIAS
|
||||||
|
storeFile=upload-keystore.jks
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Both flavors; the offline one goes to Codeberg only (issue #39).
|
||||||
|
- name: Build release APKs
|
||||||
|
run: ./gradlew assembleRelease
|
||||||
|
|
||||||
|
# Notes = a `## [X.Y.Z-beta.N]` section if there is one, else
|
||||||
|
# `## [Unreleased]`. Gitea creates the tag at this commit.
|
||||||
|
- name: Create tag + Gitea pre-release
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||||
|
SHA: ${{ github.sha }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
bash scripts/release_notes.sh "$VERSION" > release-notes.md
|
||||||
|
cat release-notes.md
|
||||||
|
TAG="v$VERSION" PRERELEASE=true NOTES_FILE=release-notes.md \
|
||||||
|
MAPPING=app/build/outputs/mapping/fullRelease/mapping.txt \
|
||||||
|
MAPPING_OFFLINE=app/build/outputs/mapping/offlineRelease/mapping.txt \
|
||||||
|
bash scripts/publish_gitea_release.sh
|
||||||
|
|
||||||
|
# The point of the whole workflow, so NOT continue-on-error.
|
||||||
|
- name: Publish pre-release to Codeberg
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.CODEBERG_RELEASE_TOKEN }}
|
||||||
|
API: https://codeberg.org/api/v1/repos/jlmakiola/agendula
|
||||||
|
SHA: ${{ github.sha }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
TAG="v$VERSION" PRERELEASE=true NOTES_FILE=release-notes.md \
|
||||||
|
APK=app/build/outputs/apk/full/release/app-full-release.apk \
|
||||||
|
APK_OFFLINE=app/build/outputs/apk/offline/release/app-offline-release.apk \
|
||||||
|
bash scripts/publish_codeberg_release.sh
|
||||||
+55
-222
@@ -7,7 +7,9 @@ name: Release — F-Droid repo + Gitea/Codeberg release + Play
|
|||||||
# the release on Codeberg with the signed APK + a SHA-256 checksum as a
|
# the release on Codeberg with the signed APK + a SHA-256 checksum as a
|
||||||
# direct-download channel — the tag is an output of the pipeline, not its
|
# direct-download channel — the tag is an output of the pipeline, not its
|
||||||
# trigger. Ordinary merges (no version bump) fall through `detect` and do
|
# trigger. Ordinary merges (no version bump) fall through `detect` and do
|
||||||
# nothing.
|
# nothing. Betas (X.Y.Z-beta.N) never come through here: beta.yaml cuts them
|
||||||
|
# from release/* branches as Codeberg-only pre-releases, and `detect` refuses
|
||||||
|
# one that reaches main.
|
||||||
#
|
#
|
||||||
# A trailing `play` job then uploads the App Bundle to Google Play. It is last
|
# A trailing `play` job then uploads the App Bundle to Google Play. It is last
|
||||||
# and separate because Play can reject a good build for reasons the pipeline
|
# and separate because Play can reject a good build for reasons the pipeline
|
||||||
@@ -46,7 +48,7 @@ jobs:
|
|||||||
# secrets. repository_owner differs between the two forges regardless of
|
# secrets. repository_owner differs between the two forges regardless of
|
||||||
# URL, proxy or instance rename, so this closes it permanently.
|
# URL, proxy or instance rename, so this closes it permanently.
|
||||||
if: github.repository_owner == 'makiolaj'
|
if: github.repository_owner == 'makiolaj'
|
||||||
runs-on: docker
|
runs-on: light
|
||||||
outputs:
|
outputs:
|
||||||
is_release: ${{ steps.v.outputs.is_release }}
|
is_release: ${{ steps.v.outputs.is_release }}
|
||||||
version: ${{ steps.v.outputs.version }}
|
version: ${{ steps.v.outputs.version }}
|
||||||
@@ -60,60 +62,32 @@ jobs:
|
|||||||
|
|
||||||
- name: Resolve version and whether it is a new release
|
- name: Resolve version and whether it is a new release
|
||||||
id: v
|
id: v
|
||||||
env:
|
|
||||||
# Tags are read from Codeberg, which is canonical — deliberately NOT
|
|
||||||
# from the Gitea API this workflow runs on. The Codeberg -> Gitea sync
|
|
||||||
# is a push mirror, i.e. `git push --mirror`, which deletes refs the
|
|
||||||
# source does not have. A tag minted here on Gitea is therefore wiped
|
|
||||||
# by the next sync (Codeberg does not have it yet) and only reappears
|
|
||||||
# once the tag push at the end of this workflow propagates back.
|
|
||||||
# Asking Gitea inside that window would report "no tag" for a release
|
|
||||||
# that already shipped, and cut it a second time.
|
|
||||||
# Public repo, so this read needs no token.
|
|
||||||
TAG_API: https://codeberg.org/api/v1/repos/jlmakiola/agendula
|
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
|
# versionName -> versionCode, channel and the pre-release flag (set
|
||||||
if [ -z "$VERSION" ]; then echo "No versionName in app/build.gradle.kts" >&2; exit 1; fi
|
# while MAJOR was 0) all come from the one script beta.yaml uses too.
|
||||||
MAJOR=$(echo "$VERSION" | cut -d. -f1); MINOR=$(echo "$VERSION" | cut -d. -f2); PATCH=$(echo "$VERSION" | cut -d. -f3)
|
INFO=$(bash scripts/version_info.sh)
|
||||||
MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0}
|
echo "$INFO"
|
||||||
VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
|
echo "$INFO" >> "$GITHUB_OUTPUT"
|
||||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
VERSION=$(echo "$INFO" | sed -n 's/^version=//p')
|
||||||
echo "version_code=$VERSION_CODE" >> "$GITHUB_OUTPUT"
|
CHANNEL=$(echo "$INFO" | sed -n 's/^channel=//p')
|
||||||
# Pre-1.0 is not stable yet: mark the Gitea release as a pre-release
|
|
||||||
# while MAJOR is 0. Graduates to a stable release automatically at 1.0.0.
|
|
||||||
if [ "$MAJOR" = "0" ]; then PRERELEASE=true; else PRERELEASE=false; fi
|
|
||||||
echo "prerelease=$PRERELEASE" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "Resolved version $VERSION (code $VERSION_CODE, prerelease=$PRERELEASE)"
|
|
||||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
echo "Manual dispatch — re-sign path, not a release."
|
echo "Manual dispatch — re-sign path, not a release."
|
||||||
echo "is_release=false" >> "$GITHUB_OUTPUT"
|
echo "is_release=false" >> "$GITHUB_OUTPUT"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
# A tag for this version already existing means the release shipped on
|
# Betas ship from release/* branches via beta.yaml and must never be
|
||||||
# an earlier push; do nothing. Absent => this merge cuts the release.
|
# cut here: this path publishes to F-Droid and Play. CI blocks such a
|
||||||
#
|
# PR into main; this is the backstop if one gets through anyway.
|
||||||
# Anything other than a clean 200/404 is treated as fatal rather than
|
if [ "$CHANNEL" != "stable" ]; then
|
||||||
# as "no tag". A Codeberg outage or a network blip would otherwise
|
echo "versionName $VERSION on main is a beta. Set the stable version before merging to main." >&2
|
||||||
# read as absent and re-cut a release that has already shipped —
|
exit 1
|
||||||
# republishing to F-Droid. Failing here is recoverable; a duplicate
|
fi
|
||||||
# release is not.
|
# Tags are read from Codeberg, the canonical forge, by exact name: its
|
||||||
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$TAG_API/git/refs/tags/v$VERSION" || echo 000)
|
# git/refs/tags API matches by prefix, so v1.1.0-beta.1 would read as
|
||||||
case "$STATUS" in
|
# v1.1.0. A lookup error is fatal rather than read as "no tag".
|
||||||
200)
|
GATE=$(bash scripts/release_gate.sh)
|
||||||
echo "Tag v$VERSION already exists on Codeberg — nothing to release."
|
echo "is_release=${GATE#cut=}" >> "$GITHUB_OUTPUT"
|
||||||
echo "is_release=false" >> "$GITHUB_OUTPUT"
|
|
||||||
;;
|
|
||||||
404)
|
|
||||||
echo "No tag for v$VERSION on Codeberg yet — cutting the release."
|
|
||||||
echo "is_release=true" >> "$GITHUB_OUTPUT"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Codeberg tag lookup for v$VERSION returned HTTP $STATUS." >&2
|
|
||||||
echo "Refusing to guess: treating this as 'no tag' could re-cut a shipped release." >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# Before a single Gradle task runs: F-Droid truncates the in-client
|
# Before a single Gradle task runs: F-Droid truncates the in-client
|
||||||
# changelog, so an over-long one would reach users cut off mid-sentence.
|
# changelog, so an over-long one would reach users cut off mid-sentence.
|
||||||
@@ -131,7 +105,7 @@ jobs:
|
|||||||
release:
|
release:
|
||||||
needs: detect
|
needs: detect
|
||||||
if: needs.detect.outputs.is_release == 'true' || github.event_name == 'workflow_dispatch'
|
if: needs.detect.outputs.is_release == 'true' || github.event_name == 'workflow_dispatch'
|
||||||
runs-on: docker
|
runs-on: gradle
|
||||||
env:
|
env:
|
||||||
ANDROID_HOME: /opt/android-sdk
|
ANDROID_HOME: /opt/android-sdk
|
||||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||||
@@ -146,10 +120,12 @@ jobs:
|
|||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
|
# JetBrains 21 is what gradle-daemon-jvm.properties asks for; installing it
|
||||||
|
# here keeps Gradle from downloading it via foojay.
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: 'zulu'
|
distribution: 'jetbrains'
|
||||||
java-version: '17'
|
java-version: '21'
|
||||||
|
|
||||||
- name: Setup Android SDK
|
- name: Setup Android SDK
|
||||||
uses: android-actions/setup-android@v3
|
uses: android-actions/setup-android@v3
|
||||||
@@ -196,8 +172,8 @@ jobs:
|
|||||||
run: chmod +x ./gradlew
|
run: chmod +x ./gradlew
|
||||||
|
|
||||||
# The committed versionName is the source of truth. Pin versionCode to the
|
# The committed versionName is the source of truth. Pin versionCode to the
|
||||||
# value derived from it so the published APK's code is always
|
# value scripts/version_info.sh derives from it, so the published APK's
|
||||||
# MAJOR*10000 + MINOR*100 + PATCH even if the committed code was forgotten.
|
# code follows the scheme even if the committed code was forgotten.
|
||||||
- name: Pin versionCode to versionName
|
- name: Pin versionCode to versionName
|
||||||
if: env.IS_RELEASE == 'true'
|
if: env.IS_RELEASE == 'true'
|
||||||
run: |
|
run: |
|
||||||
@@ -208,7 +184,7 @@ jobs:
|
|||||||
# Test the exact commit being shipped (only on a real release).
|
# Test the exact commit being shipped (only on a real release).
|
||||||
- name: Unit tests
|
- name: Unit tests
|
||||||
if: env.IS_RELEASE == 'true'
|
if: env.IS_RELEASE == 'true'
|
||||||
run: ./gradlew testDebugUnitTest
|
run: ./gradlew testFullDebugUnitTest
|
||||||
|
|
||||||
- name: Setup Android Keystore
|
- name: Setup Android Keystore
|
||||||
if: env.IS_RELEASE == 'true'
|
if: env.IS_RELEASE == 'true'
|
||||||
@@ -226,7 +202,9 @@ jobs:
|
|||||||
storeFile=upload-keystore.jks
|
storeFile=upload-keystore.jks
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
- name: Build release APK
|
# Both flavors. Only full goes to F-Droid and Play; the offline one
|
||||||
|
# (issue #39) is a Codeberg release asset and nothing else.
|
||||||
|
- name: Build release APKs
|
||||||
if: env.IS_RELEASE == 'true'
|
if: env.IS_RELEASE == 'true'
|
||||||
run: ./gradlew assembleRelease
|
run: ./gradlew assembleRelease
|
||||||
|
|
||||||
@@ -282,7 +260,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
mkdir -p fdroid/repo
|
mkdir -p fdroid/repo
|
||||||
cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/agendula_v${VERSION}.apk"
|
cp app/build/outputs/apk/full/release/app-full-release.apk "fdroid/repo/agendula_v${VERSION}.apk"
|
||||||
|
|
||||||
# Per-version "What's New": ensure this version's changelog exists in the
|
# Per-version "What's New": ensure this version's changelog exists in the
|
||||||
# fastlane tree (committed at release-cut time for the official repo; this
|
# fastlane tree (committed at release-cut time for the official repo; this
|
||||||
@@ -332,6 +310,8 @@ jobs:
|
|||||||
# Creating it with target_commitish makes Gitea create the vX.Y.Z tag at
|
# Creating it with target_commitish makes Gitea create the vX.Y.Z tag at
|
||||||
# this commit, so the tag only ever marks a fully-shipped release (and a
|
# this commit, so the tag only ever marks a fully-shipped release (and a
|
||||||
# failure before here leaves no tag, so re-running the workflow retries).
|
# failure before here leaves no tag, so re-running the workflow retries).
|
||||||
|
# Also attaches the R8 mapping (best-effort) so user crash stacktraces
|
||||||
|
# stay deobfuscatable. Notes = this version's CHANGELOG section.
|
||||||
- name: Create tag + Gitea release
|
- name: Create tag + Gitea release
|
||||||
if: env.IS_RELEASE == 'true'
|
if: env.IS_RELEASE == 'true'
|
||||||
env:
|
env:
|
||||||
@@ -340,85 +320,18 @@ jobs:
|
|||||||
SHA: ${{ github.sha }}
|
SHA: ${{ github.sha }}
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
TAG="v$VERSION"
|
bash scripts/release_notes.sh "$VERSION" > release-notes.md
|
||||||
# Notes = this version's CHANGELOG section.
|
TAG="v$VERSION" NOTES_FILE=release-notes.md \
|
||||||
awk -v ver="$VERSION" '
|
MAPPING=app/build/outputs/mapping/fullRelease/mapping.txt \
|
||||||
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
|
MAPPING_OFFLINE=app/build/outputs/mapping/offlineRelease/mapping.txt \
|
||||||
/^## \[/ { flag = 0 }
|
bash scripts/publish_gitea_release.sh
|
||||||
flag' CHANGELOG.md > release-notes.md
|
|
||||||
sed -i -e '/./,$!d' release-notes.md
|
|
||||||
if [ ! -s release-notes.md ]; then
|
|
||||||
echo "_No changelog entry for ${VERSION} — see CHANGELOG.md._" > release-notes.md
|
|
||||||
fi
|
|
||||||
python3 - "$TAG" "$SHA" "$PRERELEASE" <<'PY' > payload.json
|
|
||||||
import json, sys
|
|
||||||
print(json.dumps({
|
|
||||||
"tag_name": sys.argv[1],
|
|
||||||
"target_commitish": sys.argv[2],
|
|
||||||
"name": sys.argv[1],
|
|
||||||
"body": open("release-notes.md").read(),
|
|
||||||
"draft": False,
|
|
||||||
# Pre-1.0 releases are flagged as pre-releases (see detect job).
|
|
||||||
"prerelease": sys.argv[3] == "true",
|
|
||||||
}))
|
|
||||||
PY
|
|
||||||
# Upsert (re-run safe): PATCH if a release for the tag already exists,
|
|
||||||
# else POST a new one (which also creates the tag at target_commitish).
|
|
||||||
curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" > existing.json
|
|
||||||
ID=$(jq -r '.id // empty' existing.json 2>/dev/null || true)
|
|
||||||
if [ -n "$ID" ]; then
|
|
||||||
CODE=$(curl -s -o response.json -w '%{http_code}' -X PATCH \
|
|
||||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
|
||||||
-d @payload.json "$API/releases/$ID")
|
|
||||||
OK=200
|
|
||||||
else
|
|
||||||
CODE=$(curl -s -o response.json -w '%{http_code}' -X POST \
|
|
||||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
|
||||||
-d @payload.json "$API/releases")
|
|
||||||
OK=201
|
|
||||||
fi
|
|
||||||
cat response.json
|
|
||||||
if [ "$CODE" != "$OK" ]; then
|
|
||||||
echo "Release upsert failed with HTTP $CODE (expected $OK)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "Created/updated release $TAG at $SHA"
|
|
||||||
|
|
||||||
# Archive the R8 mapping so user crash stacktraces stay deobfuscatable.
|
|
||||||
# Attached to the release (it's not an APK, so it fits the no-binaries
|
|
||||||
# rule). Best-effort: never fail a release over it.
|
|
||||||
- name: Attach R8 mapping to Gitea release
|
|
||||||
if: env.IS_RELEASE == 'true'
|
|
||||||
continue-on-error: true
|
|
||||||
env:
|
|
||||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
MAP="app/build/outputs/mapping/release/mapping.txt"
|
|
||||||
if [ ! -f "$MAP" ]; then echo "No mapping.txt (R8 off?) — skipping."; exit 0; fi
|
|
||||||
TAG="v$VERSION"
|
|
||||||
ASSET="mapping-${VERSION}.txt.gz"
|
|
||||||
gzip -c "$MAP" > "/tmp/$ASSET"
|
|
||||||
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
|
|
||||||
if [ -z "$ID" ]; then echo "Could not resolve release id — skipping."; exit 0; fi
|
|
||||||
# Replace any prior asset of the same name (re-run safe).
|
|
||||||
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
|
|
||||||
| jq -r --arg n "$ASSET" '.[] | select(.name==$n) | .id')
|
|
||||||
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
|
|
||||||
curl -s -X POST -H "Authorization: token $TOKEN" \
|
|
||||||
-F "attachment=@/tmp/$ASSET" \
|
|
||||||
"$API/releases/$ID/assets?name=$ASSET" -o /dev/null -w "asset upload HTTP %{http_code}\n"
|
|
||||||
|
|
||||||
# Publish the release on Codeberg, which is canonical for tags and
|
# Publish the release on Codeberg, which is canonical for tags and
|
||||||
# releases (see docs/RELEASING.md). Codeberg push-mirrors branches + tags
|
# releases (see docs/RELEASING.md): push the tag there and attach the
|
||||||
# to Gitea, but releases aren't git objects and don't sync in either
|
# signed APK plus a SHA-256 checksum as the direct-download channel for
|
||||||
# direction — so this step pushes the tag straight to Codeberg and creates
|
# users who don't want F-Droid. The APK is identical to the F-Droid one
|
||||||
# the release there over the API, attaching the signed APK plus a SHA-256
|
# (same app key), so this adds no trust surface. Needs the
|
||||||
# checksum as the direct-download channel for users who don't want
|
# CODEBERG_RELEASE_TOKEN secret; skips cleanly if unset.
|
||||||
# F-Droid. The APK is identical to the F-Droid one (same app key), so this
|
|
||||||
# adds no trust surface. Needs the CODEBERG_RELEASE_TOKEN secret; skips
|
|
||||||
# cleanly if unset.
|
|
||||||
- name: Publish release to Codeberg
|
- name: Publish release to Codeberg
|
||||||
if: env.IS_RELEASE == 'true'
|
if: env.IS_RELEASE == 'true'
|
||||||
# NOT continue-on-error: this step reported green through 0.2.1, 0.2.2,
|
# NOT continue-on-error: this step reported green through 0.2.1, 0.2.2,
|
||||||
@@ -431,91 +344,11 @@ jobs:
|
|||||||
SHA: ${{ github.sha }}
|
SHA: ${{ github.sha }}
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
if [ -z "${TOKEN:-}" ]; then
|
[ -s release-notes.md ] || bash scripts/release_notes.sh "$VERSION" > release-notes.md
|
||||||
echo "CODEBERG_RELEASE_TOKEN not set — skipping Codeberg publish."
|
TAG="v$VERSION" NOTES_FILE=release-notes.md \
|
||||||
exit 0
|
APK=app/build/outputs/apk/full/release/app-full-release.apk \
|
||||||
fi
|
APK_OFFLINE=app/build/outputs/apk/offline/release/app-offline-release.apk \
|
||||||
TAG="v$VERSION"
|
bash scripts/publish_codeberg_release.sh
|
||||||
APK="app/build/outputs/apk/release/app-release.apk"
|
|
||||||
if [ ! -f "$APK" ]; then echo "No release APK found — skipping." >&2; exit 1; fi
|
|
||||||
ASSET_APK="agendula_v${VERSION}.apk"
|
|
||||||
ASSET_SUM="${ASSET_APK}.sha256"
|
|
||||||
cp "$APK" "/tmp/$ASSET_APK"
|
|
||||||
( cd /tmp && sha256sum "$ASSET_APK" > "$ASSET_SUM" )
|
|
||||||
|
|
||||||
# Release notes: reuse the section extracted for the Gitea release,
|
|
||||||
# fall back to the CHANGELOG entry if that step's file is gone.
|
|
||||||
if [ ! -s release-notes.md ]; then
|
|
||||||
awk -v ver="$VERSION" '
|
|
||||||
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
|
|
||||||
/^## \[/ { flag = 0 }
|
|
||||||
flag' CHANGELOG.md > release-notes.md
|
|
||||||
sed -i -e '/./,$!d' release-notes.md
|
|
||||||
fi
|
|
||||||
[ -s release-notes.md ] || echo "_See CHANGELOG.md for ${VERSION}._" > release-notes.md
|
|
||||||
# Push the tag to Codeberg ourselves. Under Codeberg-canonical the
|
|
||||||
# mirror runs Codeberg -> Gitea, so waiting for a tag to arrive here
|
|
||||||
# from Gitea (what 0.3.2 did) would wait forever. The tag this
|
|
||||||
# pipeline minted on Gitea is in fact *deleted* by the next mirror
|
|
||||||
# sync until Codeberg has it — so pushing it here is what makes it
|
|
||||||
# durable on both forges.
|
|
||||||
#
|
|
||||||
# Pushing the ref first and attaching with NO target_commitish is
|
|
||||||
# deliberate: a release POST carrying a target_commitish for a commit
|
|
||||||
# or tag Codeberg hasn't received yet is what produced the
|
|
||||||
# empty-bodied 500s. Attaching to a ref that already exists doesn't
|
|
||||||
# need the API to write one.
|
|
||||||
git tag -f "$TAG" "$SHA"
|
|
||||||
git push -f "https://jlmakiola:${TOKEN}@codeberg.org/jlmakiola/agendula.git" \
|
|
||||||
"refs/tags/$TAG"
|
|
||||||
python3 - "$TAG" "$PRERELEASE" <<'PY' > cb-payload.json
|
|
||||||
import json, sys
|
|
||||||
tag, pre = sys.argv[1:3]
|
|
||||||
print(json.dumps({
|
|
||||||
"tag_name": tag,
|
|
||||||
"name": tag,
|
|
||||||
"body": open("release-notes.md").read(),
|
|
||||||
"draft": False,
|
|
||||||
# Pre-1.0 releases are flagged as pre-releases (see detect job).
|
|
||||||
"prerelease": pre == "true",
|
|
||||||
}))
|
|
||||||
PY
|
|
||||||
# Create (or update) the release. Codeberg 500s on a POST/GET against a
|
|
||||||
# tag it has only just received — the release request outruns the
|
|
||||||
# indexing of the ref we pushed a moment ago — so a single attempt can
|
|
||||||
# fail even though the very same call succeeds seconds later. Retry
|
|
||||||
# with backoff, and PATCH in place if a release already exists (re-run
|
|
||||||
# safe, so re-running never disturbs a published release). A 5xx body
|
|
||||||
# still exits curl 0, so the loop, not `set -e`, controls the flow.
|
|
||||||
ID=""
|
|
||||||
for attempt in 1 2 3 4 5 6; do
|
|
||||||
EXIST=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty' 2>/dev/null || true)
|
|
||||||
if [ -n "$EXIST" ]; then
|
|
||||||
curl -s -o /dev/null -w "release PATCH HTTP %{http_code}\n" -X PATCH \
|
|
||||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
|
||||||
-d @cb-payload.json "$API/releases/$EXIST"
|
|
||||||
ID="$EXIST"; break
|
|
||||||
fi
|
|
||||||
CODE=$(curl -s -o cb-response.json -w "%{http_code}" -X POST \
|
|
||||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
|
||||||
-d @cb-payload.json "$API/releases")
|
|
||||||
echo "release POST attempt $attempt HTTP $CODE"
|
|
||||||
ID=$(jq -r '.id // empty' cb-response.json 2>/dev/null || true)
|
|
||||||
[ -n "$ID" ] && break
|
|
||||||
sleep $((attempt * 10))
|
|
||||||
done
|
|
||||||
if [ -z "$ID" ]; then echo "Could not resolve Codeberg release id after retries." >&2; exit 1; fi
|
|
||||||
|
|
||||||
# Attach APK + checksum, replacing any prior asset of the same name.
|
|
||||||
for A in "$ASSET_APK" "$ASSET_SUM"; do
|
|
||||||
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
|
|
||||||
| jq -r --arg n "$A" '.[] | select(.name==$n) | .id')
|
|
||||||
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
|
|
||||||
curl -s -X POST -H "Authorization: token $TOKEN" \
|
|
||||||
-F "attachment=@/tmp/$A" \
|
|
||||||
"$API/releases/$ID/assets?name=$A" -o /dev/null -w "asset $A HTTP %{http_code}\n"
|
|
||||||
done
|
|
||||||
echo "Published $TAG to Codeberg."
|
|
||||||
|
|
||||||
# Play takes an App Bundle, not the APK: a second artifact from the same
|
# Play takes an App Bundle, not the APK: a second artifact from the same
|
||||||
# source and signing config. Play treats the release key only as the
|
# source and signing config. Play treats the release key only as the
|
||||||
@@ -529,7 +362,7 @@ jobs:
|
|||||||
- name: Build release AAB
|
- name: Build release AAB
|
||||||
if: env.IS_RELEASE == 'true'
|
if: env.IS_RELEASE == 'true'
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
run: ./gradlew bundleRelease
|
run: ./gradlew bundleFullRelease
|
||||||
|
|
||||||
# NOT actions/upload-artifact@v4: its client refuses any non-github.com
|
# NOT actions/upload-artifact@v4: its client refuses any non-github.com
|
||||||
# server as unsupported GHES (go-gitea/gitea#36024). This fork drops that
|
# server as unsupported GHES (go-gitea/gitea#36024). This fork drops that
|
||||||
@@ -541,7 +374,7 @@ jobs:
|
|||||||
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 # v4
|
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 # v4
|
||||||
with:
|
with:
|
||||||
name: release-aab-${{ needs.detect.outputs.version }}
|
name: release-aab-${{ needs.detect.outputs.version }}
|
||||||
path: app/build/outputs/bundle/release/app-release.aab
|
path: app/build/outputs/bundle/fullRelease/app-full-release.aab
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
|
|
||||||
@@ -635,7 +468,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
# Absolute: a lane body runs from fastlane/, not the workspace root.
|
# Absolute: a lane body runs from fastlane/, not the workspace root.
|
||||||
AAB="$GITHUB_WORKSPACE/dist/app-release.aab"
|
AAB="$GITHUB_WORKSPACE/dist/app-full-release.aab"
|
||||||
test -f "$AAB" || { echo "No AAB at $AAB — the artifact handoff failed." >&2; ls -la dist || true; exit 1; }
|
test -f "$AAB" || { echo "No AAB at $AAB — the artifact handoff failed." >&2; ls -la dist || true; exit 1; }
|
||||||
bundle exec fastlane deploy \
|
bundle exec fastlane deploy \
|
||||||
aab:"$AAB" \
|
aab:"$AAB" \
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
name: Renovate
|
name: Renovate
|
||||||
|
|
||||||
on:
|
on:
|
||||||
# Weekly sweep. Mondays 05:00 UTC — this cron owns the cadence; the repo's
|
# Every merge to main. Mirror syncs from Codeberg fire push events here (the
|
||||||
# renovate.json5 deliberately has no internal schedule (avoids double-gating).
|
# same trigger release.yaml relies on), so a merged Renovate PR is followed
|
||||||
|
# within minutes by a run that rebases the sibling PRs it just conflicted —
|
||||||
|
# most bumps touch gradle/libs.versions.toml. Outside renovate.json5's
|
||||||
|
# `schedule` window such a run only maintains existing branches
|
||||||
|
# (updateNotScheduled), it never opens new PRs. Renovate's own rebases push
|
||||||
|
# to renovate/* branches, not main, so this cannot loop.
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
# Weekly sweep for new updates. Mondays 05:00 UTC, inside the schedule window
|
||||||
|
# in renovate.json5 — keep the two in step.
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '0 5 * * 1'
|
- cron: '0 5 * * 1'
|
||||||
# Manual run for an on-demand sweep from the Actions tab.
|
# Manual run for an on-demand sweep from the Actions tab.
|
||||||
|
|||||||
@@ -7,6 +7,86 @@ All notable changes to this project are documented here. The format follows
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- A reminder can count from the task's start as well as from its due date, and
|
||||||
|
tasks with only a start date get reminders too. Default reminders can mix
|
||||||
|
both, e.g. a day before the start and an hour before the due date; each
|
||||||
|
applies to tasks that have its date (#72).
|
||||||
|
- An all-day task's reminders can each have their own time, so one task can
|
||||||
|
remind you at 09:00 and again at 17:00 (#78).
|
||||||
|
- Other apps can read the tasks kept in Agendula's own database through an
|
||||||
|
OpenTasks-compatible provider, once you grant them permission. Apps that
|
||||||
|
already support OpenTasks or tasks.org need only a small change to support
|
||||||
|
Agendula too (#43).
|
||||||
|
- With a second permission, other apps can also add, edit, complete and delete
|
||||||
|
those tasks, including their reminders and tags. They can tick off a single
|
||||||
|
occurrence of a recurring task. Task lists stay read-only for them (#63).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- A new task no longer takes a default reminder whose time has already
|
||||||
|
passed, so a start of "today" does not ring the moment the task is saved.
|
||||||
|
- All-day default reminders are whole days; one set in hours rounds to the
|
||||||
|
nearest day.
|
||||||
|
- Switching a task between timed and all-day converts its reminders right
|
||||||
|
away in the editor, so what you see is what gets saved.
|
||||||
|
- The editor shows the default reminders a new task, or a newly added date,
|
||||||
|
will get, before you save. Removing them leaves the task without a reminder
|
||||||
|
instead of falling back to the default.
|
||||||
|
- Reminders now sync. A reminder set on a task is uploaded as a standard alarm
|
||||||
|
(`VALARM`), so it also notifies in macOS and iOS Reminders, Thunderbird,
|
||||||
|
tasks.org and jtx Board, and alarms set in those apps now fire in Agendula
|
||||||
|
instead of the default reminder. Removing a task's alarm in another app
|
||||||
|
turns it off here too, rather than falling back to the default (#52).
|
||||||
|
- A new task with a date, or one that gets a new date, takes the default
|
||||||
|
reminder for that date as its own, so it travels with the task to the server.
|
||||||
|
Changing the default later no longer changes those tasks; tasks that rely on
|
||||||
|
the default today are left as they are.
|
||||||
|
- On upgrade, reminders on open, synced tasks that so far stayed on this
|
||||||
|
device are uploaded once.
|
||||||
|
- Alarms imported from an `.ics` file become reminders on the task.
|
||||||
|
- Screens now open and close the way they do in Calendula: they slide in, and
|
||||||
|
a back swipe previews the screen behind before closing (#66).
|
||||||
|
- Settings now use Calendula's names and layout. Reminders is now
|
||||||
|
Notifications, Task form is now New task form, and export and import moved
|
||||||
|
out of Storage into a new Backup & restore section (#66).
|
||||||
|
|
||||||
|
## [1.1.0] - 2026-10-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Four new home-screen widgets: Today, Up next, Lists and Week.
|
||||||
|
- A widget can show tasks from several lists at once, optionally grouped by
|
||||||
|
list.
|
||||||
|
- A setting under Settings → Task form pre-fills a new task's start with today.
|
||||||
|
- An offline version of Agendula with no sync and no network access at all,
|
||||||
|
published as a separate APK on Codeberg (#39).
|
||||||
|
- Tags. Add them while editing a task, see them on the task and in lists,
|
||||||
|
browse them from the home screen and filter any list by them. They are saved
|
||||||
|
as `CATEGORIES`, so they sync with Nextcloud Tasks, tasks.org and DAVx⁵ (#35).
|
||||||
|
- Filters. Save your own view of the open tasks by due date (overdue, today,
|
||||||
|
upcoming, no date, in any combination), lists, tags and minimum priority.
|
||||||
|
They live on the home screen and can be shown in the Tasks widget, so today
|
||||||
|
and overdue tasks can now share one widget (#47).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- The Tasks widget has agenda-style rows, an optional row colour and an "All
|
||||||
|
caught up" state. Ticking a task in any widget shows it as done for a moment
|
||||||
|
before it disappears.
|
||||||
|
- The "At a glance" widget is gone; the Tasks and Today widgets cover what it
|
||||||
|
showed.
|
||||||
|
- Synced lists can be renamed and deleted, not only device-only ones. A
|
||||||
|
read-only share shows the edit button greyed out and explains why on tap.
|
||||||
|
- The sort button in task lists now opens "Sort and filter", which can also
|
||||||
|
filter by tag.
|
||||||
|
- While editing a task, its title moves into the top bar once you scroll past
|
||||||
|
it.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- The list editor's Where and smart-lists rows no longer have double padding.
|
||||||
|
- A field you tap now scrolls fully above the keyboard, and the "add subtask"
|
||||||
|
row in task lists no longer ends up hidden behind it.
|
||||||
|
- All-day tasks west of UTC no longer show up a day early in Overdue, Today
|
||||||
|
and Upcoming.
|
||||||
|
|
||||||
## [1.0.0] - 2026-09-21
|
## [1.0.0] - 2026-09-21
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+3
-3
@@ -28,9 +28,9 @@ layer.
|
|||||||
## Build, test, lint
|
## Build, test, lint
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
./gradlew :app:assembleDebug # build the debug APK
|
./gradlew :app:assembleDebug # build the debug APKs (full + offline)
|
||||||
./gradlew :app:testDebugUnitTest # JVM unit tests (JUnit5 + Truth + Turbine)
|
./gradlew :app:testFullDebugUnitTest # JVM unit tests (JUnit5 + Truth + Turbine)
|
||||||
./gradlew lintDebug # Android lint (CI runs this on every PR)
|
./gradlew lintFullDebug lintOfflineDebug # Android lint (CI runs this on every PR)
|
||||||
```
|
```
|
||||||
|
|
||||||
CI (`.forgejo/workflows/ci.yaml`, on Codeberg) runs a reproducible-release invariant check,
|
CI (`.forgejo/workflows/ci.yaml`, on Codeberg) runs a reproducible-release invariant check,
|
||||||
|
|||||||
@@ -15,11 +15,23 @@ required.</p>
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
|
<a href="https://f-droid.org/packages/de.jeanlucmakiola.agendula/"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="56"></a>
|
||||||
|
|
||||||
|
<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://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>
|
<a href="https://ko-fi.com/jeanlucmakiola"><img src="https://storage.ko-fi.com/cdn/brandasset/v2/support_me_on_kofi_badge_beige.png" alt="Support me on Ko-fi" height="56"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01.png" alt="Home screen: today's progress, overdue and upcoming tasks, and your lists" width="18%">
|
||||||
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02.png" alt="Onboarding: sync with your own CalDAV server" width="18%">
|
||||||
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03.png" alt="Upcoming list with a task expanded into its subtasks" width="18%">
|
||||||
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04.png" alt="Task detail: a monthly recurring task with a reminder and priority" width="18%">
|
||||||
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05.png" alt="Reminder notification with Done and Snooze actions" width="18%">
|
||||||
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
Agendula is the task-list sibling to [Calendula](https://codeberg.org/jlmakiola/calendula).
|
Agendula is the task-list sibling to [Calendula](https://codeberg.org/jlmakiola/calendula).
|
||||||
@@ -42,7 +54,8 @@ many small *florets* — so the two apps are florets of one bloom.)
|
|||||||
this and the following ones, or the whole series.
|
this and the following ones, or the whole series.
|
||||||
- **Reminders** that fire at the exact time: several per task, separate defaults
|
- **Reminders** that fire at the exact time: several per task, separate defaults
|
||||||
for timed and all-day tasks (globally and per list), Done and Snooze right on
|
for timed and all-day tasks (globally and per list), Done and Snooze right on
|
||||||
the notification, and they survive reboots.
|
the notification, and they survive reboots. They sync as standard `VALARM`s,
|
||||||
|
so they also ring in other CalDAV clients, and theirs ring here.
|
||||||
- **iCalendar import and export** — open an `.ics` or a zip of them, or write any
|
- **iCalendar import and export** — open an `.ics` or a zip of them, or write any
|
||||||
list out as standard `.ics` files.
|
list out as standard `.ics` files.
|
||||||
- A **home-screen widget**, launcher shortcuts, a Quick Settings tile, and
|
- A **home-screen widget**, launcher shortcuts, a Quick Settings tile, and
|
||||||
@@ -68,6 +81,16 @@ back to a full comparison where it does not. Everything the store does not model
|
|||||||
is kept verbatim and sent back unchanged, so passing your tasks through Agendula
|
is kept verbatim and sent back unchanged, so passing your tasks through Agendula
|
||||||
does not quietly lose fields another client wrote.
|
does not quietly lose fields another client wrote.
|
||||||
|
|
||||||
|
### Tested With
|
||||||
|
|
||||||
|
The following CalDAV Services have been Tested
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
| Service | Version | Date | Status | User |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **Nextcloud** | 25.0.0 | Oct 9th 2026 | ✅ | [@jlmakiola](https://codeberg.org/jlmakiola) [@Schanbro](https://codeberg.org/Schanbro)
|
||||||
|
|
||||||
## Where your tasks live
|
## Where your tasks live
|
||||||
|
|
||||||
| | Where | Sync | Needs |
|
| | Where | Sync | Needs |
|
||||||
@@ -79,6 +102,10 @@ Agendula's own store is an ordinary app database, so it **coexists with
|
|||||||
OpenTasks rather than replacing it**. If you already sync through a provider,
|
OpenTasks rather than replacing it**. If you already sync through a provider,
|
||||||
Agendula can work on top of it exactly as before.
|
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
|
Switching between the two moves nothing — each store keeps its own tasks — so
|
||||||
**Settings → Storage** asks before it switches, and offers to **copy** a
|
**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
|
provider's tasks into Agendula's own store when you want to move over. The copy
|
||||||
@@ -89,10 +116,20 @@ proprietary, and open standards — CalDAV and iCalendar — are the lane.
|
|||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
### F-Droid repository
|
### F-Droid (recommended)
|
||||||
|
|
||||||
Every release is built, signed and published to a self-hosted F-Droid
|
Agendula is in the official [F-Droid](https://f-droid.org) repository. Search
|
||||||
repository. Add it once and your F-Droid client handles updates from then on:
|
for Agendula in any F-Droid client, or
|
||||||
|
[install it from f-droid.org](https://f-droid.org/packages/de.jeanlucmakiola.agendula/).
|
||||||
|
|
||||||
|
F-Droid builds from source on its own schedule, so a new version usually shows
|
||||||
|
up there a few days after release.
|
||||||
|
|
||||||
|
### Self-hosted F-Droid repo (fastest updates)
|
||||||
|
|
||||||
|
The release pipeline builds, signs and publishes every release to a
|
||||||
|
self-hosted F-Droid repository, so new versions land there first. Add it once
|
||||||
|
and your F-Droid client handles updates from then on:
|
||||||
|
|
||||||
1. In your F-Droid client, open *Settings → Repositories → Add* (or open the
|
1. In your F-Droid client, open *Settings → Repositories → Add* (or open the
|
||||||
link below on your phone):
|
link below on your phone):
|
||||||
@@ -111,6 +148,29 @@ APK and a `.sha256` checksum attached — the same APK the F-Droid repository
|
|||||||
serves. For automatic updates from there, use
|
serves. For automatic updates from there, use
|
||||||
**[Obtainium](https://github.com/ImranR98/Obtainium)** and
|
**[Obtainium](https://github.com/ImranR98/Obtainium)** and
|
||||||
**[add Agendula in one tap](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/agendula)**.
|
**[add Agendula in one tap](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/agendula)**.
|
||||||
|
Each release carries two APKs (see [Offline version](#offline-version)), so set
|
||||||
|
*Filter APKs by regular expression* to `^agendula_v` to always get the regular
|
||||||
|
app.
|
||||||
|
Betas of upcoming versions are published there too, as pre-releases; to test
|
||||||
|
them, switch on *Include prereleases* for Agendula in Obtainium.
|
||||||
|
|
||||||
|
### Google Play
|
||||||
|
|
||||||
|
Agendula is on
|
||||||
|
[Google Play](https://play.google.com/store/apps/details?id=de.jeanlucmakiola.agendula).
|
||||||
|
|
||||||
|
Play builds are signed with Google's key rather than mine, so switching between
|
||||||
|
Play and any other channel means uninstalling first. Tasks kept only in
|
||||||
|
Agendula's own database go with it, so sync them to a CalDAV account first.
|
||||||
|
|
||||||
|
### Offline version
|
||||||
|
|
||||||
|
Each Codeberg release also carries `agendula-offline_v<version>.apk`: Agendula
|
||||||
|
without CalDAV sync and **without the network permission**, so it cannot
|
||||||
|
connect to anything. It installs alongside the regular app rather than replacing
|
||||||
|
it; move tasks between the two with Settings → Backup & restore → Export / Import. In
|
||||||
|
Obtainium, pick it with *Filter APKs by regular expression* set to
|
||||||
|
`^agendula-offline_v`.
|
||||||
|
|
||||||
### Build from source
|
### Build from source
|
||||||
|
|
||||||
@@ -126,8 +186,9 @@ JDK 17 and the Android SDK are all it needs; see
|
|||||||
## Translations
|
## Translations
|
||||||
|
|
||||||
Translations are managed on a self-hosted **Weblate**, and partial ones are
|
Translations are managed on a self-hosted **Weblate**, and partial ones are
|
||||||
fine — an untranslated string simply falls back to English. Agendula ships in
|
fine — an untranslated string simply falls back to English.
|
||||||
English, German and Brazilian Portuguese so far.
|
|
||||||
|
<a href="https://weblate.dev.jeanlucmakiola.de/engage/agendula/"><img src="https://weblate.dev.jeanlucmakiola.de/widget/agendula/multi-auto.svg" alt="Translation status" width="600"></a>
|
||||||
|
|
||||||
**→ [Help translate Agendula](https://weblate.dev.jeanlucmakiola.de/engage/agendula/)**
|
**→ [Help translate Agendula](https://weblate.dev.jeanlucmakiola.de/engage/agendula/)**
|
||||||
|
|
||||||
|
|||||||
+60
-11
@@ -1,3 +1,4 @@
|
|||||||
|
import com.android.build.api.artifact.SingleArtifact
|
||||||
import java.util.Properties
|
import java.util.Properties
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
|
|
||||||
@@ -26,12 +27,14 @@ android {
|
|||||||
// These committed values ARE the source of truth for a release: merging
|
// These committed values ARE the source of truth for a release: merging
|
||||||
// a bumped versionName into main triggers .gitea/workflows/release.yaml,
|
// a bumped versionName into main triggers .gitea/workflows/release.yaml,
|
||||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
// release itself. A versionName of X.Y.Z-beta.N pushed to a release/*
|
||||||
// PATCH from versionName, e.g. 1.0.0 -> 10000). Releases were flagged as
|
// branch instead cuts a Codeberg-only pre-release (beta.yaml).
|
||||||
// pre-releases while MAJOR was 0; 1.0.0 is the first stable one, and the
|
// versionCode is derived from versionName by scripts/version_info.sh
|
||||||
// pipeline graduates it on its own. See docs/RELEASING.md.
|
// (1.0.x: 1.0.0 -> 10000; from 1.1.0: 1.1.0-beta.1 -> 1010001,
|
||||||
versionCode = 10000
|
// 1.1.0 -> 1010099), and CI fails if the committed one doesn't match.
|
||||||
versionName = "1.0.0"
|
// See docs/RELEASING.md.
|
||||||
|
versionCode = 1020001
|
||||||
|
versionName = "1.2.0-beta.1"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
@@ -44,6 +47,20 @@ android {
|
|||||||
resValue("string", "sync_authority", "de.jeanlucmakiola.agendula.sync")
|
resValue("string", "sync_authority", "de.jeanlucmakiola.agendula.sync")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `offline` (#39): no sync code, network libraries or network permission.
|
||||||
|
flavorDimensions += "network"
|
||||||
|
productFlavors {
|
||||||
|
create("full") {
|
||||||
|
dimension = "network"
|
||||||
|
buildConfigField("boolean", "SYNC_ENABLED", "true")
|
||||||
|
}
|
||||||
|
create("offline") {
|
||||||
|
dimension = "network"
|
||||||
|
applicationIdSuffix = ".offline"
|
||||||
|
buildConfigField("boolean", "SYNC_ENABLED", "false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
if (keystorePropertiesFile.exists()) {
|
if (keystorePropertiesFile.exists()) {
|
||||||
create("release") {
|
create("release") {
|
||||||
@@ -206,26 +223,26 @@ dependencies {
|
|||||||
// @HiltWorker plumbing.
|
// @HiltWorker plumbing.
|
||||||
implementation(libs.androidx.work.runtime.ktx)
|
implementation(libs.androidx.work.runtime.ktx)
|
||||||
// Custom Tabs: the Nextcloud login flow hands the browser an approval page.
|
// Custom Tabs: the Nextcloud login flow hands the browser an approval page.
|
||||||
implementation(libs.androidx.browser)
|
"fullImplementation"(libs.androidx.browser)
|
||||||
implementation(libs.androidx.hilt.work)
|
implementation(libs.androidx.hilt.work)
|
||||||
ksp(libs.androidx.hilt.compiler)
|
ksp(libs.androidx.hilt.compiler)
|
||||||
|
|
||||||
// Push sync: a UnifiedPush distributor delivers the server's WebDAV-Push messages.
|
// Push sync: a UnifiedPush distributor delivers the server's WebDAV-Push messages.
|
||||||
implementation(libs.unifiedpush.connector)
|
"fullImplementation"(libs.unifiedpush.connector)
|
||||||
|
|
||||||
// RFC 5545 recurrence expansion, in-process; see the catalog for the pin.
|
// RFC 5545 recurrence expansion, in-process; see the catalog for the pin.
|
||||||
implementation(libs.dmfs.lib.recur)
|
implementation(libs.dmfs.lib.recur)
|
||||||
|
|
||||||
// Vendored dav4jvm — the CalDAV protocol layer. See dav/PROVENANCE.md.
|
// Vendored dav4jvm — the CalDAV protocol layer. See dav/PROVENANCE.md.
|
||||||
implementation(project(":dav"))
|
"fullImplementation"(project(":dav"))
|
||||||
// Discovery, auth and Nextcloud Login Flow v2.
|
// Discovery, auth and Nextcloud Login Flow v2.
|
||||||
implementation(project(":caldav"))
|
"fullImplementation"(project(":caldav"))
|
||||||
// :dav gets org.xmlpull.v1 from the Android framework at runtime and declares
|
// :dav gets org.xmlpull.v1 from the Android framework at runtime and declares
|
||||||
// xpp3 compileOnly, which is not transitive. Unit tests run on a plain JVM
|
// xpp3 compileOnly, which is not transitive. Unit tests run on a plain JVM
|
||||||
// with no framework, and android.jar's stub factory returns null under
|
// with no framework, and android.jar's stub factory returns null under
|
||||||
// isReturnDefaultValues — so anything touching XmlUtils would NPE without a
|
// isReturnDefaultValues — so anything touching XmlUtils would NPE without a
|
||||||
// real implementation here.
|
// real implementation here.
|
||||||
testImplementation(libs.xpp3)
|
"testFullImplementation"(libs.xpp3)
|
||||||
|
|
||||||
implementation(libs.androidx.room.runtime)
|
implementation(libs.androidx.room.runtime)
|
||||||
implementation(libs.androidx.room.ktx)
|
implementation(libs.androidx.room.ktx)
|
||||||
@@ -244,6 +261,7 @@ dependencies {
|
|||||||
implementation(libs.floret.core.crash)
|
implementation(libs.floret.core.crash)
|
||||||
implementation(libs.floret.identity)
|
implementation(libs.floret.identity)
|
||||||
implementation(libs.floret.components)
|
implementation(libs.floret.components)
|
||||||
|
implementation(libs.floret.glance)
|
||||||
|
|
||||||
debugImplementation(libs.androidx.ui.tooling)
|
debugImplementation(libs.androidx.ui.tooling)
|
||||||
debugImplementation(libs.androidx.ui.test.manifest)
|
debugImplementation(libs.androidx.ui.test.manifest)
|
||||||
@@ -263,3 +281,34 @@ dependencies {
|
|||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
androidTestImplementation(libs.androidx.ui.test.junit4)
|
androidTestImplementation(libs.androidx.ui.test.junit4)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fails the build if the offline flavor's merged manifest asks for the network. */
|
||||||
|
abstract class VerifyNoNetworkPermissions : DefaultTask() {
|
||||||
|
@get:InputFile
|
||||||
|
@get:PathSensitive(PathSensitivity.NONE)
|
||||||
|
abstract val manifest: RegularFileProperty
|
||||||
|
|
||||||
|
@TaskAction
|
||||||
|
fun verify() {
|
||||||
|
val text = manifest.get().asFile.readText()
|
||||||
|
val found = listOf("INTERNET", "ACCESS_NETWORK_STATE", "ACCESS_WIFI_STATE", "CHANGE_NETWORK_STATE")
|
||||||
|
.filter { "\"android.permission.$it\"" in text }
|
||||||
|
if (found.isNotEmpty()) {
|
||||||
|
throw GradleException(
|
||||||
|
"The offline flavor's merged manifest declares ${found.joinToString()}. " +
|
||||||
|
"Remove it in app/src/offline/AndroidManifest.xml with tools:node=\"remove\".",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
androidComponents {
|
||||||
|
onVariants(selector().withFlavor("network" to "offline")) { variant ->
|
||||||
|
val name = variant.name.replaceFirstChar { it.uppercase() }
|
||||||
|
val verify = tasks.register<VerifyNoNetworkPermissions>("verify${name}Manifest") {
|
||||||
|
manifest.set(variant.artifacts.get(SingleArtifact.MERGED_MANIFEST))
|
||||||
|
}
|
||||||
|
tasks.matching { it.name in setOf("package$name", "package${name}Bundle", "check") }
|
||||||
|
.configureEach { dependsOn(verify) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,529 @@
|
|||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 2,
|
||||||
|
"identityHash": "14b159c51416ba27653f6320ac7f6f03",
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"tableName": "accounts",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `display_name` TEXT NOT NULL, `principal_url` TEXT, `home_set_url` TEXT, `username` TEXT, `last_sync_at` INTEGER, `last_sync_error` TEXT)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "displayName",
|
||||||
|
"columnName": "display_name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "principalUrl",
|
||||||
|
"columnName": "principal_url",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "homeSetUrl",
|
||||||
|
"columnName": "home_set_url",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "username",
|
||||||
|
"columnName": "username",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lastSyncAt",
|
||||||
|
"columnName": "last_sync_at",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lastSyncError",
|
||||||
|
"columnName": "last_sync_error",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "task_lists",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `account_id` INTEGER, `is_visible` INTEGER NOT NULL DEFAULT 1, `is_synced` INTEGER NOT NULL DEFAULT 1, `owner` TEXT, `is_read_only` INTEGER NOT NULL DEFAULT 0, `sort_order` INTEGER NOT NULL DEFAULT 0, `href` TEXT, `ctag` TEXT, `sync_token` TEXT, `is_dirty` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`account_id`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "color",
|
||||||
|
"columnName": "color",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "accountId",
|
||||||
|
"columnName": "account_id",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isVisible",
|
||||||
|
"columnName": "is_visible",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isSynced",
|
||||||
|
"columnName": "is_synced",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "owner",
|
||||||
|
"columnName": "owner",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isReadOnly",
|
||||||
|
"columnName": "is_read_only",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "sortOrder",
|
||||||
|
"columnName": "sort_order",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "href",
|
||||||
|
"columnName": "href",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "ctag",
|
||||||
|
"columnName": "ctag",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "syncToken",
|
||||||
|
"columnName": "sync_token",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isDirty",
|
||||||
|
"columnName": "is_dirty",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_task_lists_account_id",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"account_id"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_task_lists_account_id` ON `${TABLE_NAME}` (`account_id`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "accounts",
|
||||||
|
"onDelete": "SET NULL",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"account_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "tasks",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `list_id` INTEGER NOT NULL, `uid` TEXT NOT NULL, `href` TEXT, `etag` TEXT, `title` TEXT, `description` TEXT, `location` TEXT, `url` TEXT, `color` INTEGER, `categories` TEXT NOT NULL DEFAULT '', `status` INTEGER NOT NULL DEFAULT 0, `percent_complete` INTEGER, `completed_at` INTEGER, `priority` INTEGER NOT NULL DEFAULT 0, `classification` INTEGER, `dtstart` INTEGER, `due` INTEGER, `duration` TEXT, `is_all_day` INTEGER NOT NULL DEFAULT 0, `timezone` TEXT, `rrule` TEXT, `rdate` TEXT, `exdate` TEXT, `recurrence_id` INTEGER, `master_id` INTEGER, `parent_id` INTEGER, `sort_order` INTEGER NOT NULL DEFAULT 0, `created_at` INTEGER, `last_modified` INTEGER, `sequence` INTEGER NOT NULL DEFAULT 0, `is_dirty` INTEGER NOT NULL DEFAULT 0, `is_deleted` INTEGER NOT NULL DEFAULT 0, `unknown_properties` TEXT, FOREIGN KEY(`list_id`) REFERENCES `task_lists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`master_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parent_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "listId",
|
||||||
|
"columnName": "list_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "uid",
|
||||||
|
"columnName": "uid",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "href",
|
||||||
|
"columnName": "href",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "etag",
|
||||||
|
"columnName": "etag",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "title",
|
||||||
|
"columnName": "title",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "description",
|
||||||
|
"columnName": "description",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "location",
|
||||||
|
"columnName": "location",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "url",
|
||||||
|
"columnName": "url",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "color",
|
||||||
|
"columnName": "color",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "categories",
|
||||||
|
"columnName": "categories",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "''"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "status",
|
||||||
|
"columnName": "status",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "percentComplete",
|
||||||
|
"columnName": "percent_complete",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "completedAt",
|
||||||
|
"columnName": "completed_at",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "priority",
|
||||||
|
"columnName": "priority",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "classification",
|
||||||
|
"columnName": "classification",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "dtstart",
|
||||||
|
"columnName": "dtstart",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "due",
|
||||||
|
"columnName": "due",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "duration",
|
||||||
|
"columnName": "duration",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isAllDay",
|
||||||
|
"columnName": "is_all_day",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "timezone",
|
||||||
|
"columnName": "timezone",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "rrule",
|
||||||
|
"columnName": "rrule",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "rdate",
|
||||||
|
"columnName": "rdate",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "exdate",
|
||||||
|
"columnName": "exdate",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "recurrenceId",
|
||||||
|
"columnName": "recurrence_id",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "masterId",
|
||||||
|
"columnName": "master_id",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "parentId",
|
||||||
|
"columnName": "parent_id",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "sortOrder",
|
||||||
|
"columnName": "sort_order",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "created_at",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lastModified",
|
||||||
|
"columnName": "last_modified",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "sequence",
|
||||||
|
"columnName": "sequence",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isDirty",
|
||||||
|
"columnName": "is_dirty",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isDeleted",
|
||||||
|
"columnName": "is_deleted",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "unknownProperties",
|
||||||
|
"columnName": "unknown_properties",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_tasks_list_id_is_deleted",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"list_id",
|
||||||
|
"is_deleted"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_list_id_is_deleted` ON `${TABLE_NAME}` (`list_id`, `is_deleted`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_tasks_parent_id",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"parent_id"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_parent_id` ON `${TABLE_NAME}` (`parent_id`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_tasks_master_id_recurrence_id",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"master_id",
|
||||||
|
"recurrence_id"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_master_id_recurrence_id` ON `${TABLE_NAME}` (`master_id`, `recurrence_id`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_tasks_is_dirty",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"is_dirty"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_is_dirty` ON `${TABLE_NAME}` (`is_dirty`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_tasks_list_id_uid_recurrence_id",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"list_id",
|
||||||
|
"uid",
|
||||||
|
"recurrence_id"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tasks_list_id_uid_recurrence_id` ON `${TABLE_NAME}` (`list_id`, `uid`, `recurrence_id`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "task_lists",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"list_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "tasks",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"master_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "tasks",
|
||||||
|
"onDelete": "SET NULL",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"parent_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "task_alarms",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `task_id` INTEGER NOT NULL, `minutes_before` INTEGER NOT NULL, `reference` TEXT NOT NULL DEFAULT 'DUE', `message` TEXT, FOREIGN KEY(`task_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "taskId",
|
||||||
|
"columnName": "task_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "minutesBefore",
|
||||||
|
"columnName": "minutes_before",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "reference",
|
||||||
|
"columnName": "reference",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "'DUE'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "message",
|
||||||
|
"columnName": "message",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_task_alarms_task_id",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"task_id"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_task_alarms_task_id` ON `${TABLE_NAME}` (`task_id`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "tasks",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"task_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"setupQueries": [
|
||||||
|
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||||
|
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '14b159c51416ba27653f6320ac7f6f03')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,548 @@
|
|||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 3,
|
||||||
|
"identityHash": "725292b38bce25ce9032c4650573ae85",
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"tableName": "accounts",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `display_name` TEXT NOT NULL, `principal_url` TEXT, `home_set_url` TEXT, `username` TEXT, `last_sync_at` INTEGER, `last_sync_error` TEXT)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "displayName",
|
||||||
|
"columnName": "display_name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "principalUrl",
|
||||||
|
"columnName": "principal_url",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "homeSetUrl",
|
||||||
|
"columnName": "home_set_url",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "username",
|
||||||
|
"columnName": "username",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lastSyncAt",
|
||||||
|
"columnName": "last_sync_at",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lastSyncError",
|
||||||
|
"columnName": "last_sync_error",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "task_lists",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `account_id` INTEGER, `is_visible` INTEGER NOT NULL DEFAULT 1, `is_synced` INTEGER NOT NULL DEFAULT 1, `owner` TEXT, `is_read_only` INTEGER NOT NULL DEFAULT 0, `sort_order` INTEGER NOT NULL DEFAULT 0, `href` TEXT, `ctag` TEXT, `sync_token` TEXT, `is_dirty` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`account_id`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "color",
|
||||||
|
"columnName": "color",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "accountId",
|
||||||
|
"columnName": "account_id",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isVisible",
|
||||||
|
"columnName": "is_visible",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isSynced",
|
||||||
|
"columnName": "is_synced",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "owner",
|
||||||
|
"columnName": "owner",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isReadOnly",
|
||||||
|
"columnName": "is_read_only",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "sortOrder",
|
||||||
|
"columnName": "sort_order",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "href",
|
||||||
|
"columnName": "href",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "ctag",
|
||||||
|
"columnName": "ctag",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "syncToken",
|
||||||
|
"columnName": "sync_token",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isDirty",
|
||||||
|
"columnName": "is_dirty",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_task_lists_account_id",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"account_id"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_task_lists_account_id` ON `${TABLE_NAME}` (`account_id`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "accounts",
|
||||||
|
"onDelete": "SET NULL",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"account_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "tasks",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `list_id` INTEGER NOT NULL, `uid` TEXT NOT NULL, `href` TEXT, `etag` TEXT, `title` TEXT, `description` TEXT, `location` TEXT, `url` TEXT, `color` INTEGER, `categories` TEXT NOT NULL DEFAULT '', `status` INTEGER NOT NULL DEFAULT 0, `percent_complete` INTEGER, `completed_at` INTEGER, `priority` INTEGER NOT NULL DEFAULT 0, `classification` INTEGER, `dtstart` INTEGER, `due` INTEGER, `duration` TEXT, `is_all_day` INTEGER NOT NULL DEFAULT 0, `timezone` TEXT, `rrule` TEXT, `rdate` TEXT, `exdate` TEXT, `recurrence_id` INTEGER, `master_id` INTEGER, `parent_id` INTEGER, `sort_order` INTEGER NOT NULL DEFAULT 0, `created_at` INTEGER, `last_modified` INTEGER, `sequence` INTEGER NOT NULL DEFAULT 0, `is_dirty` INTEGER NOT NULL DEFAULT 0, `is_deleted` INTEGER NOT NULL DEFAULT 0, `unknown_properties` TEXT, `reminders_off` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`list_id`) REFERENCES `task_lists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`master_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parent_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "listId",
|
||||||
|
"columnName": "list_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "uid",
|
||||||
|
"columnName": "uid",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "href",
|
||||||
|
"columnName": "href",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "etag",
|
||||||
|
"columnName": "etag",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "title",
|
||||||
|
"columnName": "title",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "description",
|
||||||
|
"columnName": "description",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "location",
|
||||||
|
"columnName": "location",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "url",
|
||||||
|
"columnName": "url",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "color",
|
||||||
|
"columnName": "color",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "categories",
|
||||||
|
"columnName": "categories",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "''"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "status",
|
||||||
|
"columnName": "status",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "percentComplete",
|
||||||
|
"columnName": "percent_complete",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "completedAt",
|
||||||
|
"columnName": "completed_at",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "priority",
|
||||||
|
"columnName": "priority",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "classification",
|
||||||
|
"columnName": "classification",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "dtstart",
|
||||||
|
"columnName": "dtstart",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "due",
|
||||||
|
"columnName": "due",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "duration",
|
||||||
|
"columnName": "duration",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isAllDay",
|
||||||
|
"columnName": "is_all_day",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "timezone",
|
||||||
|
"columnName": "timezone",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "rrule",
|
||||||
|
"columnName": "rrule",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "rdate",
|
||||||
|
"columnName": "rdate",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "exdate",
|
||||||
|
"columnName": "exdate",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "recurrenceId",
|
||||||
|
"columnName": "recurrence_id",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "masterId",
|
||||||
|
"columnName": "master_id",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "parentId",
|
||||||
|
"columnName": "parent_id",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "sortOrder",
|
||||||
|
"columnName": "sort_order",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "created_at",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lastModified",
|
||||||
|
"columnName": "last_modified",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "sequence",
|
||||||
|
"columnName": "sequence",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isDirty",
|
||||||
|
"columnName": "is_dirty",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isDeleted",
|
||||||
|
"columnName": "is_deleted",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "unknownProperties",
|
||||||
|
"columnName": "unknown_properties",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "remindersOff",
|
||||||
|
"columnName": "reminders_off",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_tasks_list_id_is_deleted",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"list_id",
|
||||||
|
"is_deleted"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_list_id_is_deleted` ON `${TABLE_NAME}` (`list_id`, `is_deleted`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_tasks_parent_id",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"parent_id"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_parent_id` ON `${TABLE_NAME}` (`parent_id`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_tasks_master_id_recurrence_id",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"master_id",
|
||||||
|
"recurrence_id"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_master_id_recurrence_id` ON `${TABLE_NAME}` (`master_id`, `recurrence_id`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_tasks_is_dirty",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"is_dirty"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_is_dirty` ON `${TABLE_NAME}` (`is_dirty`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_tasks_list_id_uid_recurrence_id",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"list_id",
|
||||||
|
"uid",
|
||||||
|
"recurrence_id"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tasks_list_id_uid_recurrence_id` ON `${TABLE_NAME}` (`list_id`, `uid`, `recurrence_id`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "task_lists",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"list_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "tasks",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"master_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "tasks",
|
||||||
|
"onDelete": "SET NULL",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"parent_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "task_alarms",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `task_id` INTEGER NOT NULL, `minutes_before` INTEGER NOT NULL, `reference` TEXT NOT NULL DEFAULT 'DUE', `message` TEXT, `uid` TEXT NOT NULL DEFAULT '', `raw` TEXT, FOREIGN KEY(`task_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "taskId",
|
||||||
|
"columnName": "task_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "minutesBefore",
|
||||||
|
"columnName": "minutes_before",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "reference",
|
||||||
|
"columnName": "reference",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "'DUE'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "message",
|
||||||
|
"columnName": "message",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "uid",
|
||||||
|
"columnName": "uid",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "''"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "raw",
|
||||||
|
"columnName": "raw",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_task_alarms_task_id",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"task_id"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_task_alarms_task_id` ON `${TABLE_NAME}` (`task_id`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "tasks",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"task_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"setupQueries": [
|
||||||
|
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||||
|
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '725292b38bce25ce9032c4650573ae85')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
+9
@@ -212,6 +212,15 @@ class OneShotImportTest {
|
|||||||
assertThat(db.alarms().all().map { it.message }).doesNotContain("Errands")
|
assertThat(db.alarms().all().map { it.message }).doesNotContain("Errands")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun importsCategoryPropertiesAsTags() {
|
||||||
|
importer.importFrom(fixture())
|
||||||
|
val tasks = taskRows()
|
||||||
|
|
||||||
|
assertThat(tasks.getValue("Buy milk").categories).containsExactly("Errands")
|
||||||
|
assertThat(tasks.getValue("Ship the release").categories).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
// --- running it -----------------------------------------------------------
|
// --- running it -----------------------------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
+157
@@ -0,0 +1,157 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks.provider
|
||||||
|
|
||||||
|
import android.content.ContentProviderOperation
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PermissionInfo
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.ParcelFileDescriptor
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.Assert.assertThrows
|
||||||
|
import org.junit.Assume.assumeTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
/** The provider as another app meets it: manifest, permission, URIs. */
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class AgendulaTaskProviderTest {
|
||||||
|
|
||||||
|
private val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
private val authority = "${context.packageName}.tasks"
|
||||||
|
private val readPermission = "${context.packageName}.permission.READ_TASKS"
|
||||||
|
private val writePermission = "${context.packageName}.permission.WRITE_TASKS"
|
||||||
|
private val resolver = context.contentResolver
|
||||||
|
|
||||||
|
private fun uri(path: String) = "content://$authority/$path".toUri()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isExportedUnderItsPermissions() {
|
||||||
|
val info = requireNotNull(context.packageManager.resolveContentProvider(authority, 0))
|
||||||
|
|
||||||
|
assertThat(info.name).isEqualTo(AgendulaTaskProvider::class.java.name)
|
||||||
|
assertThat(info.exported).isTrue()
|
||||||
|
assertThat(info.readPermission).isEqualTo(readPermission)
|
||||||
|
assertThat(info.writePermission).isEqualTo(writePermission)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun bothPermissionsAreDangerous() {
|
||||||
|
listOf(readPermission, writePermission).forEach { permission ->
|
||||||
|
val info = context.packageManager.getPermissionInfo(permission, 0)
|
||||||
|
|
||||||
|
assertThat(info.protection).isEqualTo(PermissionInfo.PROTECTION_DANGEROUS)
|
||||||
|
assertThat(info.loadLabel(context.packageManager).toString()).isNotEmpty()
|
||||||
|
assertThat(info.loadDescription(context.packageManager)?.toString()).isNotEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anAppWithoutThePermissionIsDenied() {
|
||||||
|
assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
||||||
|
// The shell is another uid that was never granted it.
|
||||||
|
val output = shell("content query --uri content://$authority/tasks")
|
||||||
|
|
||||||
|
assertThat(output).contains("Permission Denial")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anAppWithoutTheWritePermissionCannotWrite() {
|
||||||
|
assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
||||||
|
|
||||||
|
val output = shell("content insert --uri content://$authority/tasks --bind title:s:x --bind list_id:i:1")
|
||||||
|
|
||||||
|
assertThat(output).contains("Permission Denial")
|
||||||
|
assertThat(output).contains(writePermission)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reportsOpenTasksMimeTypes() {
|
||||||
|
ProviderTable.entries.forEach { table ->
|
||||||
|
assertThat(resolver.getType(uri(table.path))).isEqualTo(table.mimeType(single = false))
|
||||||
|
assertThat(resolver.getType(uri("${table.path}/3"))).isEqualTo(table.mimeType(single = true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun everyTableAnswersWithItsColumns() {
|
||||||
|
ProviderTable.entries.forEach { table ->
|
||||||
|
resolver.query(uri(table.path), null, null, null, null)!!.use {
|
||||||
|
assertThat(it.columnNames.toList()).containsExactlyElementsIn(table.columns.map { c -> c.first }).inOrder()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aCursorWatchesTheUriItWasQueriedFor() {
|
||||||
|
val tasks = uri("tasks")
|
||||||
|
|
||||||
|
resolver.query(tasks, null, null, null, null)!!.use { assertThat(it.notificationUri).isEqualTo(tasks) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aRowUriReadsOnlyThatRow() {
|
||||||
|
// Ids start at 1, so 0 names nothing whatever the store holds.
|
||||||
|
resolver.query(uri("tasks/0"), null, null, null, null)!!.use { assertThat(it.count).isEqualTo(0) }
|
||||||
|
resolver.query(uri("tasks/0"), null, "1 = 1 OR 1 = 1", null, null)!!.use { assertThat(it.count).isEqualTo(0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anUnknownUriIsRejected() {
|
||||||
|
assertThrows(IllegalArgumentException::class.java) { resolver.query(uri("events"), null, null, null, null) }
|
||||||
|
// ContentResolver.getType swallows the provider's exception and answers null.
|
||||||
|
assertThat(runCatching { resolver.getType(uri("tasks/abc")) }.getOrNull()).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun listsAreReadOnly() {
|
||||||
|
val values = ContentValues().apply { put("list_name", "x") }
|
||||||
|
|
||||||
|
assertThrows(UnsupportedOperationException::class.java) { resolver.insert(uri("tasklists"), values) }
|
||||||
|
assertThrows(UnsupportedOperationException::class.java) { resolver.update(uri("tasklists/1"), values, null, null) }
|
||||||
|
assertThrows(UnsupportedOperationException::class.java) { resolver.delete(uri("tasklists/1"), null, null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aRowUriTakesNoInsert() {
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
resolver.insert(uri("tasks/1"), ContentValues().apply { put("title", "x") })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aBadWriteReachesTheClientAsIllegalArgument() {
|
||||||
|
// No list has this id, so nothing in the app's real store is touched.
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put("list_id", Long.MAX_VALUE)
|
||||||
|
put("title", "x")
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException::class.java) { resolver.insert(uri("tasks"), values) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aFailingBatchWritesNothing() {
|
||||||
|
val missingList = Long.MAX_VALUE
|
||||||
|
val operations = arrayListOf(
|
||||||
|
ContentProviderOperation.newInsert(uri("tasks")).withValue("list_id", missingList).withValue("title", "a").build(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException::class.java) { resolver.applyBatch(authority, operations) }
|
||||||
|
resolver.query(uri("tasks"), arrayOf("_id"), "list_id = ?", arrayOf(missingList.toString()), null)!!
|
||||||
|
.use { assertThat(it.count).isEqualTo(0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** stdout and stderr of [command]; the denial lands on stderr. */
|
||||||
|
private fun shell(command: String): String {
|
||||||
|
val (stdout, stdin, stderr) = InstrumentationRegistry.getInstrumentation().uiAutomation
|
||||||
|
.executeShellCommandRwe(command)
|
||||||
|
stdin.close()
|
||||||
|
return listOf(stdout, stderr).joinToString("\n") { pfd ->
|
||||||
|
ParcelFileDescriptor.AutoCloseInputStream(pfd).bufferedReader().use { it.readText() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+214
@@ -0,0 +1,214 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks.provider
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.database.ContentObserver
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderEnvironment
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import java.util.concurrent.LinkedBlockingQueue
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Duration.Companion.days
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/** The snapshot follows the Room store, the storage mode and the clock, and says when it moved. */
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class ProviderSnapshotSourceTest {
|
||||||
|
|
||||||
|
private val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
private lateinit var db: TasksDatabase
|
||||||
|
private lateinit var resolver: ProviderResolver
|
||||||
|
private lateinit var source: ProviderSnapshotSource
|
||||||
|
private val clock = MutableClock(Instant.parse("2026-10-10T08:00:00Z"))
|
||||||
|
private val changes = LinkedBlockingQueue<Uri>()
|
||||||
|
private val observer = object : ContentObserver(null) {
|
||||||
|
override fun onChange(selfChange: Boolean, uri: Uri?) {
|
||||||
|
changes += uri ?: Uri.EMPTY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private var listId = 0L
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
db = Room.inMemoryDatabaseBuilder(context, TasksDatabase::class.java).allowMainThreadQueries().build()
|
||||||
|
resolver = ProviderResolver(NoProviderInstalled)
|
||||||
|
source = ProviderSnapshotSource(context, db, resolver, awaitStartup = {}, clock = clock)
|
||||||
|
listId = db.taskLists().insert(TaskListEntity(name = "Home", color = 0xFF112233.toInt()))
|
||||||
|
context.contentResolver.registerContentObserver(TasksContract.authorityUri(source.authority), true, observer)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
context.contentResolver.unregisterContentObserver(observer)
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun query(table: ProviderTable, selection: String? = null, args: Array<String>? = null): Cursor =
|
||||||
|
source.query(table, null, selection, args, null)
|
||||||
|
|
||||||
|
private fun titles(): List<String?> = query(ProviderTable.TASKS).use { cursor ->
|
||||||
|
val index = cursor.getColumnIndexOrThrow(Tasks.TITLE)
|
||||||
|
buildList { while (cursor.moveToNext()) add(cursor.getString(index)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun insert(title: String, due: Instant? = null, rrule: String? = null): Long =
|
||||||
|
db.tasks().insert(TaskEntity(listId = listId, uid = title, title = title, due = due, rrule = rrule, timezone = "UTC"))
|
||||||
|
|
||||||
|
/** Waits for the coalesced notification, then drains any stragglers. */
|
||||||
|
private fun awaitChange() {
|
||||||
|
assertThat(changes.poll(5, TimeUnit.SECONDS)).isNotNull()
|
||||||
|
Thread.sleep(700)
|
||||||
|
changes.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertNoChange() {
|
||||||
|
assertThat(changes.poll(1500, TimeUnit.MILLISECONDS)).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun servesTheOwnStore() {
|
||||||
|
val id = insert("Milk")
|
||||||
|
db.alarms().insert(TaskAlarmEntity(taskId = id, minutesBefore = 10))
|
||||||
|
|
||||||
|
assertThat(titles()).containsExactly("Milk")
|
||||||
|
query(ProviderTable.LISTS).use {
|
||||||
|
it.moveToFirst()
|
||||||
|
assertThat(it.getString(it.getColumnIndexOrThrow(Lists.NAME))).isEqualTo("Home")
|
||||||
|
}
|
||||||
|
query(ProviderTable.INSTANCES).use { assertThat(it.count).isEqualTo(1) }
|
||||||
|
query(ProviderTable.PROPERTIES).use { assertThat(it.count).isEqualTo(1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aDeletedTaskIsNotPublished() {
|
||||||
|
val id = insert("Gone")
|
||||||
|
insert("Kept")
|
||||||
|
db.tasks().markDeleted(id, clock.now())
|
||||||
|
|
||||||
|
assertThat(titles()).containsExactly("Kept")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aWriteNotifiesObserversAndIsServedNext() {
|
||||||
|
source.start()
|
||||||
|
assertThat(titles()).isEmpty()
|
||||||
|
|
||||||
|
insert("Later")
|
||||||
|
|
||||||
|
awaitChange()
|
||||||
|
assertThat(titles()).containsExactly("Later")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aBurstOfWritesNotifiesOnce() {
|
||||||
|
source.start()
|
||||||
|
titles()
|
||||||
|
|
||||||
|
repeat(5) { insert("t$it") }
|
||||||
|
|
||||||
|
assertThat(changes.poll(5, TimeUnit.SECONDS)).isNotNull()
|
||||||
|
assertNoChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun externalModeServesNothing() {
|
||||||
|
insert("Mine")
|
||||||
|
resolver.storageMode = StorageMode.EXTERNAL
|
||||||
|
|
||||||
|
ProviderTable.entries.forEach { table -> query(table).use { assertThat(it.count).isEqualTo(0) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun switchingModeNotifiesAndRebuilds() {
|
||||||
|
source.start()
|
||||||
|
insert("Mine")
|
||||||
|
assertThat(titles()).containsExactly("Mine")
|
||||||
|
changes.clear()
|
||||||
|
|
||||||
|
resolver.storageMode = StorageMode.EXTERNAL
|
||||||
|
awaitChange()
|
||||||
|
assertThat(titles()).isEmpty()
|
||||||
|
|
||||||
|
resolver.storageMode = StorageMode.OWN
|
||||||
|
awaitChange()
|
||||||
|
assertThat(titles()).containsExactly("Mine")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun writesInExternalModeDoNotNotify() {
|
||||||
|
resolver.storageMode = StorageMode.EXTERNAL
|
||||||
|
source.start()
|
||||||
|
titles()
|
||||||
|
|
||||||
|
insert("Hidden")
|
||||||
|
|
||||||
|
assertNoChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aClientSelectionNarrowsTheRows() {
|
||||||
|
insert("a")
|
||||||
|
insert("b")
|
||||||
|
|
||||||
|
query(ProviderTable.TASKS, "${Tasks.TITLE} = ?", arrayOf("b")).use { assertThat(it.count).isEqualTo(1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anHourOldSnapshotIsRebuiltAgainstTheClock() {
|
||||||
|
source.start()
|
||||||
|
insert("Daily", due = Instant.parse("2026-10-10T09:00:00Z"), rrule = "FREQ=DAILY;COUNT=3")
|
||||||
|
awaitChange()
|
||||||
|
assertThat(currentOriginalTime()).isEqualTo(Instant.parse("2026-10-10T09:00:00Z").toEpochMilliseconds())
|
||||||
|
|
||||||
|
clock.current += 1.days
|
||||||
|
|
||||||
|
assertThat(currentOriginalTime()).isEqualTo(Instant.parse("2026-10-11T09:00:00Z").toEpochMilliseconds())
|
||||||
|
awaitChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aFreshSnapshotIsReused() {
|
||||||
|
insert("a")
|
||||||
|
titles()
|
||||||
|
|
||||||
|
clock.current += 30.minutes
|
||||||
|
|
||||||
|
query(ProviderTable.INSTANCES).use { assertThat(it.count).isEqualTo(1) }
|
||||||
|
assertNoChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun currentOriginalTime(): Long =
|
||||||
|
query(ProviderTable.INSTANCES, "${Instances.DISTANCE_FROM_CURRENT} = 0").use {
|
||||||
|
assertThat(it.moveToFirst()).isTrue()
|
||||||
|
it.getLong(it.getColumnIndexOrThrow(Instances.INSTANCE_ORIGINAL_TIME))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class MutableClock(var current: Instant) : Clock {
|
||||||
|
override fun now(): Instant = current
|
||||||
|
}
|
||||||
|
|
||||||
|
private object NoProviderInstalled : ProviderEnvironment {
|
||||||
|
override fun packageDeclaring(authority: String): String? = null
|
||||||
|
override fun isGranted(permission: String): Boolean = false
|
||||||
|
override fun appLabel(packageName: String): String? = null
|
||||||
|
}
|
||||||
+811
@@ -0,0 +1,811 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks.provider
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderEnvironment
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Alarm
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Category
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertThrows
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Duration.Companion.days
|
||||||
|
import kotlin.time.Duration.Companion.hours
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlinx.coroutines.awaitCancellation
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/** Writes from another app, applied to the own store through the published tables. */
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class ProviderWriterTest {
|
||||||
|
|
||||||
|
private val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
private lateinit var db: TasksDatabase
|
||||||
|
private lateinit var resolver: ProviderResolver
|
||||||
|
private lateinit var source: ProviderSnapshotSource
|
||||||
|
private lateinit var writer: ProviderWriter
|
||||||
|
private var afterWrites = 0
|
||||||
|
private var home = 0L
|
||||||
|
|
||||||
|
/** Truncated to the store's granularity, and on the hour so occurrences land on round times. */
|
||||||
|
private val today = Instant.fromEpochMilliseconds(Clock.System.now().toEpochMilliseconds() / 3_600_000 * 3_600_000)
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
db = Room.inMemoryDatabaseBuilder(context, TasksDatabase::class.java).allowMainThreadQueries().build()
|
||||||
|
resolver = ProviderResolver(NoProvider)
|
||||||
|
source = ProviderSnapshotSource(context, db, resolver, awaitStartup = {})
|
||||||
|
writer = ProviderWriter(db, RoomTasksDataSource(db), source, resolver, afterWrite = { afterWrites++ })
|
||||||
|
home = db.taskLists().insert(TaskListEntity(name = "Home", color = 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() = db.close()
|
||||||
|
|
||||||
|
private fun task(id: Long) = db.tasks().entity(id)
|
||||||
|
|
||||||
|
private fun insert(vararg values: Pair<String, Any?>): Long =
|
||||||
|
writer.insert(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to home, *values))
|
||||||
|
|
||||||
|
private fun byId(table: ProviderTable, id: Long) = "${table.idColumn} = $id"
|
||||||
|
|
||||||
|
private fun synced(readOnly: Boolean = false): Long {
|
||||||
|
val account = db.accounts().insert(AccountEntity(displayName = "me", username = "me"))
|
||||||
|
return db.taskLists().insert(
|
||||||
|
TaskListEntity(name = "Work", color = 0, accountId = account, href = "https://s/w/", isReadOnly = readOnly),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dailySeries(title: String = "Daily"): Long = db.tasks().insert(
|
||||||
|
TaskEntity(listId = home, uid = title, title = title, dtstart = today - 1.days, due = today - 1.days + 1.hours,
|
||||||
|
rrule = "FREQ=DAILY;COUNT=5", timezone = "UTC"),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Each instance of [taskId]'s series by its original time, with its task row and status. */
|
||||||
|
private fun instances(): List<Triple<Long, Long, Int>> =
|
||||||
|
source.rows(
|
||||||
|
ProviderTable.INSTANCES,
|
||||||
|
listOf(Instances.INSTANCE_ORIGINAL_TIME, Instances.TASK_ID, Tasks.STATUS),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
).map { (original, taskId, status) -> Triple(original as Long, taskId as Long, (status as Long).toInt()) }
|
||||||
|
|
||||||
|
private fun instanceId(original: Instant): Long =
|
||||||
|
source.rows(
|
||||||
|
ProviderTable.INSTANCES,
|
||||||
|
listOf(Tasks.ID),
|
||||||
|
"${Instances.INSTANCE_ORIGINAL_TIME} = ?",
|
||||||
|
arrayOf(original.toEpochMilliseconds().toString()),
|
||||||
|
).single().single() as Long
|
||||||
|
|
||||||
|
// --- inserts ----------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anInsertedTaskIsPublishedAndOwedASync() {
|
||||||
|
val id = insert(Tasks.TITLE to "Milk", Tasks.PRIORITY to 1)
|
||||||
|
|
||||||
|
val stored = task(id)!!
|
||||||
|
assertThat(stored.title).isEqualTo("Milk")
|
||||||
|
assertThat(stored.priority).isEqualTo(1)
|
||||||
|
assertThat(stored.isDirty).isTrue()
|
||||||
|
assertThat(source.rows(ProviderTable.TASKS, listOf(Tasks.TITLE), byId(ProviderTable.TASKS, id), null))
|
||||||
|
.containsExactly(listOf("Milk"))
|
||||||
|
assertThat(afterWrites).isEqualTo(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anInsertNeedsAListThatExistsAndIsWritable() {
|
||||||
|
assertThrows(IllegalArgumentException::class.java) { writer.insert(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x")) }
|
||||||
|
assertThrows(IllegalArgumentException::class.java) { insert(Tasks.LIST_ID to 999L) }
|
||||||
|
val readOnly = synced(readOnly = true)
|
||||||
|
assertThrows(UnsupportedOperationException::class.java) { insert(Tasks.LIST_ID to readOnly) }
|
||||||
|
assertThat(db.tasks().live()).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anOccurrenceCannotBeInsertedAsATask() {
|
||||||
|
val series = dailySeries()
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
insert(Tasks.ORIGINAL_INSTANCE_ID to series, Tasks.ORIGINAL_INSTANCE_TIME to today.toEpochMilliseconds())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aDuplicateUidIsRejected() {
|
||||||
|
insert(Tasks.UID to "same")
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException::class.java) { insert(Tasks.UID to "same") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun listsAndInstancesTakeNoInserts() {
|
||||||
|
assertThrows(UnsupportedOperationException::class.java) {
|
||||||
|
writer.insert(ProviderTable.LISTS, mapOf(TasksContract.Lists.NAME to "New"))
|
||||||
|
}
|
||||||
|
assertThrows(UnsupportedOperationException::class.java) { writer.insert(ProviderTable.INSTANCES, emptyMap()) }
|
||||||
|
assertThrows(UnsupportedOperationException::class.java) {
|
||||||
|
writer.update(ProviderTable.LISTS, mapOf(TasksContract.Lists.NAME to "x"), null, null)
|
||||||
|
}
|
||||||
|
assertThrows(UnsupportedOperationException::class.java) { writer.delete(ProviderTable.LISTS, null, null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anUnknownColumnIsRejected() {
|
||||||
|
assertThrows(IllegalArgumentException::class.java) { insert("etag" to "x") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nothingIsWrittenInExternalMode() {
|
||||||
|
val id = insert(Tasks.TITLE to "Mine")
|
||||||
|
resolver.storageMode = StorageMode.EXTERNAL
|
||||||
|
|
||||||
|
assertThrows(IllegalStateException::class.java) { insert() }
|
||||||
|
assertThrows(IllegalStateException::class.java) {
|
||||||
|
writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), null, null)
|
||||||
|
}
|
||||||
|
assertThat(task(id)!!.title).isEqualTo("Mine")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- updates ----------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anUpdateChangesTheRowsItsSelectionMatches() {
|
||||||
|
val a = insert(Tasks.TITLE to "a")
|
||||||
|
val b = insert(Tasks.TITLE to "b")
|
||||||
|
|
||||||
|
val count = writer.update(ProviderTable.TASKS, mapOf(Tasks.STATUS to 2), "${Tasks.TITLE} = ?", arrayOf("b"))
|
||||||
|
|
||||||
|
assertThat(count).isEqualTo(1)
|
||||||
|
assertThat(task(a)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||||
|
assertThat(task(b)!!.status).isEqualTo(TaskStatus.COMPLETED)
|
||||||
|
assertThat(task(b)!!.completedAt).isNotNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anUpdateMatchingNothingChangesNothing() {
|
||||||
|
insert(Tasks.TITLE to "a")
|
||||||
|
|
||||||
|
assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), "${Tasks.TITLE} = 'zzz'", null)).isEqualTo(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aTaskInAReadOnlyListCannotChange() {
|
||||||
|
val readOnly = synced(readOnly = true)
|
||||||
|
val id = db.tasks().insert(TaskEntity(listId = readOnly, uid = "r", title = "theirs"))
|
||||||
|
|
||||||
|
assertThrows(UnsupportedOperationException::class.java) {
|
||||||
|
writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "mine"), byId(ProviderTable.TASKS, id), null)
|
||||||
|
}
|
||||||
|
assertThrows(UnsupportedOperationException::class.java) {
|
||||||
|
writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, id), null)
|
||||||
|
}
|
||||||
|
assertThat(task(id)!!.title).isEqualTo("theirs")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aTaskMovesBetweenLists() {
|
||||||
|
val work = synced()
|
||||||
|
val id = insert(Tasks.TITLE to "t")
|
||||||
|
|
||||||
|
writer.update(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to work), byId(ProviderTable.TASKS, id), null)
|
||||||
|
|
||||||
|
assertThat(db.tasks().live().single { it.title == "t" }.listId).isEqualTo(work)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aParentMustBeInTheSameListAndNotADescendant() {
|
||||||
|
val work = synced()
|
||||||
|
val parent = insert(Tasks.TITLE to "parent")
|
||||||
|
val child = insert(Tasks.TITLE to "child", Tasks.PARENT_ID to parent)
|
||||||
|
val elsewhere = insert(Tasks.LIST_ID to work, Tasks.TITLE to "elsewhere")
|
||||||
|
|
||||||
|
assertThat(task(child)!!.parentId).isEqualTo(parent)
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
writer.update(ProviderTable.TASKS, mapOf(Tasks.PARENT_ID to elsewhere), byId(ProviderTable.TASKS, child), null)
|
||||||
|
}
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
writer.update(ProviderTable.TASKS, mapOf(Tasks.PARENT_ID to child), byId(ProviderTable.TASKS, parent), null)
|
||||||
|
}
|
||||||
|
assertThrows(IllegalArgumentException::class.java) { insert(Tasks.PARENT_ID to 999L) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun movingASeriesDropsTheOverridesItNoLongerGenerates() {
|
||||||
|
val series = dailySeries()
|
||||||
|
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||||
|
assertThat(db.tasks().overrides(series)).hasSize(1)
|
||||||
|
|
||||||
|
writer.update(
|
||||||
|
ProviderTable.TASKS,
|
||||||
|
mapOf(Tasks.DTSTART to (today + 2.hours).toEpochMilliseconds(), Tasks.DUE to (today + 3.hours).toEpochMilliseconds()),
|
||||||
|
byId(ProviderTable.TASKS, series),
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(db.tasks().overrides(series)).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aClientCannotTouchSyncState() {
|
||||||
|
val work = synced()
|
||||||
|
val id = db.tasks().insert(
|
||||||
|
TaskEntity(listId = work, uid = "s", title = "t", href = "/s.ics", etag = "e", sequence = 2, unknownProperties = "X-A:1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "new", "_sync_id" to "x", "sync1" to "y"), byId(ProviderTable.TASKS, id), null)
|
||||||
|
|
||||||
|
val stored = task(id)!!
|
||||||
|
assertThat(stored.title).isEqualTo("new")
|
||||||
|
assertThat(stored.href).isEqualTo("/s.ics")
|
||||||
|
assertThat(stored.etag).isEqualTo("e")
|
||||||
|
assertThat(stored.sequence).isEqualTo(2)
|
||||||
|
assertThat(stored.unknownProperties).isEqualTo("X-A:1")
|
||||||
|
assertThat(stored.isDirty).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- instances --------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun completingAnOccurrenceTicksOffOnlyThatOne() {
|
||||||
|
val series = dailySeries()
|
||||||
|
|
||||||
|
val count = writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||||
|
|
||||||
|
assertThat(count).isEqualTo(1)
|
||||||
|
assertThat(task(series)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||||
|
val override = db.tasks().overrides(series).single()
|
||||||
|
assertThat(override.recurrenceId).isEqualTo(today)
|
||||||
|
assertThat(override.status).isEqualTo(TaskStatus.COMPLETED)
|
||||||
|
val statuses = instances().associate { it.first to it.third }
|
||||||
|
assertThat(statuses[today.toEpochMilliseconds()]).isEqualTo(TasksContract.STATUS_COMPLETED)
|
||||||
|
assertThat(statuses.filterKeys { it != today.toEpochMilliseconds() }.values.toSet())
|
||||||
|
.containsExactly(TasksContract.STATUS_NEEDS_ACTION)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anOccurrenceCanBeCancelledAndReopened() {
|
||||||
|
val series = dailySeries()
|
||||||
|
|
||||||
|
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 3), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||||
|
assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.CANCELLED)
|
||||||
|
|
||||||
|
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.PERCENT_COMPLETE to 0), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||||
|
assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun contentWrittenToAnOccurrenceEditsTheSeries() {
|
||||||
|
val series = dailySeries()
|
||||||
|
|
||||||
|
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.TITLE to "Renamed"), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||||
|
|
||||||
|
assertThat(task(series)!!.title).isEqualTo("Renamed")
|
||||||
|
assertThat(db.tasks().overrides(series)).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anOccurrenceCannotBeMoved() {
|
||||||
|
dailySeries()
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.DUE to 0L), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anOccurrenceHasNoPartialProgress() {
|
||||||
|
dailySeries()
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.PERCENT_COMPLETE to 50), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aPlainTaskInstanceWritesTheTask() {
|
||||||
|
val id = insert(Tasks.TITLE to "t", Tasks.DUE to today.toEpochMilliseconds())
|
||||||
|
val instance = source.rows(ProviderTable.INSTANCES, listOf(Tasks.ID), "${Instances.TASK_ID} = $id", null).single().single() as Long
|
||||||
|
|
||||||
|
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.PERCENT_COMPLETE to 50, Tasks.DUE to (today + 1.days).toEpochMilliseconds()), byId(ProviderTable.INSTANCES, instance), null)
|
||||||
|
|
||||||
|
assertThat(task(id)!!.status).isEqualTo(TaskStatus.IN_PROCESS)
|
||||||
|
assertThat(task(id)!!.due).isEqualTo(today + 1.days)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deletingAnOccurrenceExceptsIt() {
|
||||||
|
val series = dailySeries()
|
||||||
|
|
||||||
|
assertThat(writer.delete(ProviderTable.INSTANCES, byId(ProviderTable.INSTANCES, instanceId(today)), null)).isEqualTo(1)
|
||||||
|
|
||||||
|
assertThat(task(series)!!.exdate).isNotNull()
|
||||||
|
assertThat(instances().map { it.first }).doesNotContain(today.toEpochMilliseconds())
|
||||||
|
assertThat(instances()).hasSize(4)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deletingAModifiedOccurrenceExceptsItToo() {
|
||||||
|
val series = dailySeries()
|
||||||
|
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||||
|
|
||||||
|
writer.delete(ProviderTable.INSTANCES, byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||||
|
|
||||||
|
assertThat(db.tasks().overrides(series)).isEmpty()
|
||||||
|
assertThat(instances().map { it.first }).doesNotContain(today.toEpochMilliseconds())
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- deletes ----------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aParentGoesWithItsSubtasks() {
|
||||||
|
val parent = insert(Tasks.TITLE to "parent")
|
||||||
|
val child = insert(Tasks.TITLE to "child", Tasks.PARENT_ID to parent)
|
||||||
|
val grandchild = insert(Tasks.TITLE to "grandchild", Tasks.PARENT_ID to child)
|
||||||
|
val other = insert(Tasks.TITLE to "other")
|
||||||
|
|
||||||
|
assertThat(writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, parent), null)).isEqualTo(1)
|
||||||
|
|
||||||
|
assertThat(task(parent)).isNull()
|
||||||
|
assertThat(task(child)).isNull()
|
||||||
|
assertThat(task(grandchild)).isNull()
|
||||||
|
assertThat(task(other)).isNotNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aSyncedTaskLeavesATombstone() {
|
||||||
|
val work = synced()
|
||||||
|
val id = insert(Tasks.LIST_ID to work, Tasks.TITLE to "t")
|
||||||
|
|
||||||
|
writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, id), null)
|
||||||
|
|
||||||
|
assertThat(task(id)!!.isDeleted).isTrue()
|
||||||
|
assertThat(source.rows(ProviderTable.TASKS, listOf(Tasks.ID), null, null)).isEmpty()
|
||||||
|
assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), byId(ProviderTable.TASKS, id), null)).isEqualTo(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deletingAnOverrideThroughTasksExceptsTheOccurrence() {
|
||||||
|
val series = dailySeries()
|
||||||
|
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||||
|
val override = db.tasks().overrides(series).single().id
|
||||||
|
|
||||||
|
writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, override), null)
|
||||||
|
|
||||||
|
assertThat(task(series)).isNotNull()
|
||||||
|
assertThat(task(series)!!.exdate).isNotNull()
|
||||||
|
assertThat(instances()).hasSize(4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- properties -------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anAlarmCanBeAddedChangedAndRemoved() {
|
||||||
|
val id = insert(Tasks.TITLE to "t", Tasks.DUE to (today + 1.days).toEpochMilliseconds())
|
||||||
|
db.tasks().update(task(id)!!.copy(isDirty = false))
|
||||||
|
|
||||||
|
val propertyId = writer.insert(
|
||||||
|
ProviderTable.PROPERTIES,
|
||||||
|
mapOf(Properties.TASK_ID to id, Properties.MIMETYPE to Alarm.MIMETYPE, Alarm.MINUTES_BEFORE to "15", Alarm.REFERENCE to "1"),
|
||||||
|
)
|
||||||
|
val alarm = db.alarms().forTask(id).single()
|
||||||
|
assertThat(alarm.minutesBefore).isEqualTo(15)
|
||||||
|
assertThat(alarm.reference).isEqualTo(AlarmReference.DUE)
|
||||||
|
assertThat(task(id)!!.isDirty).isTrue()
|
||||||
|
assertThat(propertyId).isEqualTo(ProviderIds.alarm(alarm.id))
|
||||||
|
|
||||||
|
writer.update(ProviderTable.PROPERTIES, mapOf(Alarm.MINUTES_BEFORE to 60, Alarm.REFERENCE to 2), byId(ProviderTable.PROPERTIES, propertyId), null)
|
||||||
|
assertThat(db.alarms().forTask(id).single().minutesBefore).isEqualTo(60)
|
||||||
|
assertThat(db.alarms().forTask(id).single().reference).isEqualTo(AlarmReference.START)
|
||||||
|
|
||||||
|
writer.delete(ProviderTable.PROPERTIES, "${Properties.MIMETYPE} = ?", arrayOf(Alarm.MIMETYPE))
|
||||||
|
assertThat(db.alarms().forTask(id)).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tagsCanBeAddedRenamedAndRemoved() {
|
||||||
|
val id = insert(Tasks.TITLE to "t")
|
||||||
|
|
||||||
|
val propertyId = writer.insert(
|
||||||
|
ProviderTable.PROPERTIES,
|
||||||
|
mapOf(Properties.TASK_ID to id, Properties.MIMETYPE to Category.MIMETYPE, Category.NAME to " errand "),
|
||||||
|
)
|
||||||
|
assertThat(task(id)!!.categories).containsExactly("errand")
|
||||||
|
|
||||||
|
writer.update(ProviderTable.PROPERTIES, mapOf(Category.NAME to "shopping"), byId(ProviderTable.PROPERTIES, propertyId), null)
|
||||||
|
assertThat(task(id)!!.categories).containsExactly("shopping")
|
||||||
|
|
||||||
|
writer.insert(ProviderTable.PROPERTIES, mapOf(Properties.TASK_ID to id, Properties.MIMETYPE to Category.MIMETYPE, Category.NAME to "shopping"))
|
||||||
|
assertThat(task(id)!!.categories).containsExactly("shopping")
|
||||||
|
|
||||||
|
writer.delete(ProviderTable.PROPERTIES, "${Category.NAME} = ?", arrayOf("shopping"))
|
||||||
|
assertThat(task(id)!!.categories).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aPropertyCannotChangeItsKindOrTask() {
|
||||||
|
val id = insert(Tasks.TITLE to "t")
|
||||||
|
val other = insert(Tasks.TITLE to "o")
|
||||||
|
db.alarms().insert(TaskAlarmEntity(taskId = id, minutesBefore = 5))
|
||||||
|
val propertyId = source.rows(ProviderTable.PROPERTIES, listOf(Properties.PROPERTY_ID), null, null).single().single() as Long
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
writer.update(ProviderTable.PROPERTIES, mapOf(Properties.MIMETYPE to Category.MIMETYPE), byId(ProviderTable.PROPERTIES, propertyId), null)
|
||||||
|
}
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
writer.update(ProviderTable.PROPERTIES, mapOf(Properties.TASK_ID to other), byId(ProviderTable.PROPERTIES, propertyId), null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun onlyAlarmsAndTagsCanBeWritten() {
|
||||||
|
val id = insert(Tasks.TITLE to "t")
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
writer.insert(ProviderTable.PROPERTIES, mapOf(Properties.TASK_ID to id, Properties.MIMETYPE to "vnd.android.cursor.item/comment"))
|
||||||
|
}
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
writer.insert(ProviderTable.PROPERTIES, mapOf(Properties.TASK_ID to id, Properties.MIMETYPE to Alarm.MIMETYPE))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- batches ----------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aBatchIsAllOrNothing() {
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
writer.batch {
|
||||||
|
insert(Tasks.TITLE to "first")
|
||||||
|
insert(Tasks.TITLE to "second")
|
||||||
|
insert(Tasks.LIST_ID to 999L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThat(db.tasks().live()).isEmpty()
|
||||||
|
assertThat(source.rows(ProviderTable.TASKS, listOf(Tasks.ID), null, null)).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aBatchSeesItsOwnWritesAndReArmsRemindersOnce() {
|
||||||
|
writer.batch {
|
||||||
|
insert(Tasks.TITLE to "first")
|
||||||
|
writer.update(ProviderTable.TASKS, mapOf(Tasks.STATUS to 2), "${Tasks.TITLE} = ?", arrayOf("first"))
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThat(db.tasks().live().single().status).isEqualTo(TaskStatus.COMPLETED)
|
||||||
|
assertThat(afterWrites).isEqualTo(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- review regressions -----------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aBatchDeletingTwoPropertiesDeletesThoseTwo() {
|
||||||
|
val a = insert(Tasks.TITLE to "a")
|
||||||
|
val b = insert(Tasks.TITLE to "b")
|
||||||
|
listOf(5, 10).forEach { db.alarms().insert(TaskAlarmEntity(taskId = a, minutesBefore = it)) }
|
||||||
|
db.alarms().insert(TaskAlarmEntity(taskId = b, minutesBefore = 15))
|
||||||
|
val ids = source.rows(ProviderTable.PROPERTIES, listOf(Properties.PROPERTY_ID), "${Properties.TASK_ID} = $a", null)
|
||||||
|
.map { it.single() as Long }
|
||||||
|
|
||||||
|
writer.batch { ids.forEach { writer.delete(ProviderTable.PROPERTIES, byId(ProviderTable.PROPERTIES, it), null) } }
|
||||||
|
|
||||||
|
assertThat(db.alarms().forTask(a)).isEmpty()
|
||||||
|
assertThat(db.alarms().forTask(b).map { it.minutesBefore }).containsExactly(15)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aBatchDeletingTwoOccurrencesDeletesThoseTwo() {
|
||||||
|
dailySeries()
|
||||||
|
val first = instanceId(today)
|
||||||
|
val second = instanceId(today + 1.days)
|
||||||
|
|
||||||
|
writer.batch {
|
||||||
|
writer.delete(ProviderTable.INSTANCES, byId(ProviderTable.INSTANCES, first), null)
|
||||||
|
writer.delete(ProviderTable.INSTANCES, byId(ProviderTable.INSTANCES, second), null)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThat(instances().map { it.first }).containsExactly(
|
||||||
|
(today - 1.days).toEpochMilliseconds(),
|
||||||
|
(today + 2.days).toEpochMilliseconds(),
|
||||||
|
(today + 3.days).toEpochMilliseconds(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anInstanceIdOutlivesAnotherOccurrencesDeletion() {
|
||||||
|
dailySeries()
|
||||||
|
val later = instanceId(today + 2.days)
|
||||||
|
|
||||||
|
writer.delete(ProviderTable.INSTANCES, byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||||
|
|
||||||
|
assertThat(instanceId(today + 2.days)).isEqualTo(later)
|
||||||
|
assertThat(writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, later), null))
|
||||||
|
.isEqualTo(1)
|
||||||
|
assertThat(instances().single { it.third == TasksContract.STATUS_COMPLETED }.first)
|
||||||
|
.isEqualTo((today + 2.days).toEpochMilliseconds())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aRecurringSubtaskOutsideTheWindowGoesWithItsParent() {
|
||||||
|
val parent = insert(Tasks.TITLE to "parent")
|
||||||
|
val longAgo = today - 1000.days
|
||||||
|
val subtask = db.tasks().insert(
|
||||||
|
TaskEntity(listId = home, uid = "old", title = "old", dtstart = longAgo, rrule = "FREQ=DAILY;COUNT=2",
|
||||||
|
timezone = "UTC", parentId = parent),
|
||||||
|
)
|
||||||
|
|
||||||
|
writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, parent), null)
|
||||||
|
|
||||||
|
assertThat(task(subtask)).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anOverriddenRecurringSubtaskGoesWholeWithItsParent() {
|
||||||
|
val parent = insert(Tasks.TITLE to "parent")
|
||||||
|
val subtask = dailySeries("sub")
|
||||||
|
db.tasks().update(task(subtask)!!.copy(parentId = parent))
|
||||||
|
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||||
|
|
||||||
|
writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, parent), null)
|
||||||
|
|
||||||
|
assertThat(task(subtask)).isNull()
|
||||||
|
assertThat(db.tasks().live()).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aModifiedOccurrenceCanBeWrittenBackAsRead() {
|
||||||
|
val series = dailySeries()
|
||||||
|
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||||
|
val override = db.tasks().overrides(series).single().id
|
||||||
|
val row = readRow(ProviderTable.TASKS, override)
|
||||||
|
|
||||||
|
writer.update(ProviderTable.TASKS, row + (Tasks.TITLE to "Renamed"), byId(ProviderTable.TASKS, override), null)
|
||||||
|
|
||||||
|
assertThat(task(override)!!.title).isEqualTo("Renamed")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anInstanceCanBeWrittenBackAsReadToCompleteIt() {
|
||||||
|
val series = dailySeries()
|
||||||
|
val instance = instanceId(today)
|
||||||
|
val row = readRow(ProviderTable.INSTANCES, instance)
|
||||||
|
|
||||||
|
writer.update(ProviderTable.INSTANCES, row + (Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instance), null)
|
||||||
|
|
||||||
|
assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.COMPLETED)
|
||||||
|
assertThat(task(series)!!.dtstart).isEqualTo(today - 1.days)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun movingASubtreeMovesItsSubtasks() {
|
||||||
|
val work = synced()
|
||||||
|
val parent = insert(Tasks.TITLE to "parent")
|
||||||
|
val child = insert(Tasks.TITLE to "child", Tasks.PARENT_ID to parent)
|
||||||
|
val grandchild = insert(Tasks.TITLE to "grandchild", Tasks.PARENT_ID to child)
|
||||||
|
|
||||||
|
writer.update(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to work), byId(ProviderTable.TASKS, parent), null)
|
||||||
|
|
||||||
|
assertThat(listOf(parent, child, grandchild).map { task(it)!!.listId }).containsExactly(work, work, work)
|
||||||
|
assertThat(task(child)!!.parentId).isEqualTo(parent)
|
||||||
|
assertThat(task(grandchild)!!.parentId).isEqualTo(child)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aMoveOntoATakenUidIsRejected() {
|
||||||
|
val work = synced()
|
||||||
|
insert(Tasks.LIST_ID to work, Tasks.UID to "same")
|
||||||
|
val id = insert(Tasks.UID to "same")
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
writer.update(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to work), byId(ProviderTable.TASKS, id), null)
|
||||||
|
}
|
||||||
|
assertThat(task(id)!!.listId).isEqualTo(home)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun contentOnAModifiedOccurrenceStillEditsTheSeries() {
|
||||||
|
val series = dailySeries()
|
||||||
|
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||||
|
val override = db.tasks().overrides(series).single().id
|
||||||
|
|
||||||
|
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.TITLE to "Renamed"), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||||
|
|
||||||
|
assertThat(task(series)!!.title).isEqualTo("Renamed")
|
||||||
|
assertThat(task(override)!!.title).isEqualTo("Renamed")
|
||||||
|
assertThat(task(override)!!.status).isEqualTo(TaskStatus.COMPLETED)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aWriteThatChangesNothingOwesNoSync() {
|
||||||
|
val id = insert(Tasks.TITLE to "t")
|
||||||
|
db.tasks().update(task(id)!!.copy(isDirty = false))
|
||||||
|
val modified = task(id)!!.lastModified
|
||||||
|
|
||||||
|
writer.update(ProviderTable.TASKS, readRow(ProviderTable.TASKS, id), byId(ProviderTable.TASKS, id), null)
|
||||||
|
writer.update(ProviderTable.TASKS, emptyMap(), byId(ProviderTable.TASKS, id), null)
|
||||||
|
|
||||||
|
assertThat(task(id)!!.isDirty).isFalse()
|
||||||
|
assertThat(task(id)!!.lastModified).isEqualTo(modified)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reopeningAnOpenOccurrenceForksNothing() {
|
||||||
|
val series = dailySeries()
|
||||||
|
|
||||||
|
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 0), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||||
|
|
||||||
|
assertThat(db.tasks().overrides(series)).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aWriteBeforeStartupSettlesIsRefused() {
|
||||||
|
val stuck = ProviderSnapshotSource(context, db, resolver, awaitStartup = { awaitCancellation() }, startupWait = 50.milliseconds)
|
||||||
|
val early = ProviderWriter(db, RoomTasksDataSource(db), stuck, resolver, afterWrite = {})
|
||||||
|
|
||||||
|
assertThrows(IllegalStateException::class.java) { early.insert(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to home)) }
|
||||||
|
assertThat(db.tasks().live()).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aSingleRowWriteNeedsNoSnapshot() {
|
||||||
|
val id = insert(Tasks.TITLE to "t")
|
||||||
|
val unknown = 987_654L
|
||||||
|
|
||||||
|
assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), byId(ProviderTable.TASKS, unknown), null)).isEqualTo(0)
|
||||||
|
assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), byId(ProviderTable.TASKS, id), null)).isEqualTo(1)
|
||||||
|
assertThat(writer.update(ProviderTable.INSTANCES, mapOf(Tasks.TITLE to "x"), byId(ProviderTable.INSTANCES, unknown), null)).isEqualTo(0)
|
||||||
|
assertThat(writer.delete(ProviderTable.PROPERTIES, byId(ProviderTable.PROPERTIES, unknown), null)).isEqualTo(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- second review regressions -----------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tickingOffAModifiedOccurrenceAsReadLeavesTheSeriesAlone() {
|
||||||
|
val series = dailySeries("Standup")
|
||||||
|
// Reopening forks an override, which then gets its own title as the app's "this occurrence" edit would.
|
||||||
|
store().setCompletedInstance(series, today, false)
|
||||||
|
val override = db.tasks().overrides(series).single()
|
||||||
|
db.tasks().update(override.copy(title = "Standup (moved room)"))
|
||||||
|
val row = readRow(ProviderTable.INSTANCES, instanceId(today))
|
||||||
|
|
||||||
|
writer.update(ProviderTable.INSTANCES, row + (Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||||
|
|
||||||
|
assertThat(task(series)!!.title).isEqualTo("Standup")
|
||||||
|
assertThat(task(override.id)!!.title).isEqualTo("Standup (moved room)")
|
||||||
|
assertThat(task(override.id)!!.status).isEqualTo(TaskStatus.COMPLETED)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anInstanceWrittenBackWithOnlyProgressChangedIsCompleted() {
|
||||||
|
val series = dailySeries()
|
||||||
|
val row = readRow(ProviderTable.INSTANCES, instanceId(today))
|
||||||
|
|
||||||
|
writer.update(ProviderTable.INSTANCES, row + (Tasks.PERCENT_COMPLETE to 100), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||||
|
|
||||||
|
assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.COMPLETED)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aBadZoneOnAnInstanceReportsTheZone() {
|
||||||
|
dailySeries()
|
||||||
|
val instance = instanceId(today)
|
||||||
|
|
||||||
|
val error = assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.TZ to "Europe/Berln"), byId(ProviderTable.INSTANCES, instance), null)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThat(error).hasMessageThat().contains("Unknown time zone")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anInProcessSeriesCanBeWrittenBackAsRead() {
|
||||||
|
val series = dailySeries()
|
||||||
|
db.tasks().update(task(series)!!.copy(status = TaskStatus.IN_PROCESS, percentComplete = 50))
|
||||||
|
val row = readRow(ProviderTable.INSTANCES, instanceId(today))
|
||||||
|
|
||||||
|
writer.update(ProviderTable.INSTANCES, row + (Tasks.TITLE to "Renamed"), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||||
|
|
||||||
|
assertThat(task(series)!!.title).isEqualTo("Renamed")
|
||||||
|
assertThat(task(series)!!.status).isEqualTo(TaskStatus.IN_PROCESS)
|
||||||
|
assertThat(db.tasks().overrides(series)).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aFloatingSeriesCanBeWrittenBackAsRead() {
|
||||||
|
val series = db.tasks().insert(
|
||||||
|
TaskEntity(listId = home, uid = "f", title = "Floating", due = today, rrule = "FREQ=DAILY;COUNT=3", timezone = null),
|
||||||
|
)
|
||||||
|
val row = readRow(ProviderTable.INSTANCES, instanceId(today))
|
||||||
|
|
||||||
|
writer.update(ProviderTable.INSTANCES, row + (Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||||
|
writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "Still floating"), byId(ProviderTable.TASKS, series), null)
|
||||||
|
|
||||||
|
assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.COMPLETED)
|
||||||
|
assertThat(task(series)!!.timezone).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aParentLoopDeletesInsteadOfRecursingForever() {
|
||||||
|
val a = insert(Tasks.TITLE to "a")
|
||||||
|
val b = insert(Tasks.TITLE to "b", Tasks.PARENT_ID to a)
|
||||||
|
db.tasks().update(task(a)!!.copy(parentId = b))
|
||||||
|
|
||||||
|
writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, a), null)
|
||||||
|
|
||||||
|
assertThat(task(a)).isNull()
|
||||||
|
assertThat(task(b)).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aBulkMoveWorksWhicheverOrderTheRowsComeIn() {
|
||||||
|
val work = synced()
|
||||||
|
// The subtask gets the lower id, so it comes first unless the writer reorders.
|
||||||
|
val child = insert(Tasks.TITLE to "child")
|
||||||
|
val parent = insert(Tasks.TITLE to "parent")
|
||||||
|
db.tasks().update(task(child)!!.copy(parentId = parent))
|
||||||
|
|
||||||
|
val count = writer.update(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to work), "${Tasks.LIST_ID} = ?", arrayOf(home.toString()))
|
||||||
|
|
||||||
|
assertThat(count).isEqualTo(2)
|
||||||
|
assertThat(task(child)!!.listId).isEqualTo(work)
|
||||||
|
assertThat(task(parent)!!.listId).isEqualTo(work)
|
||||||
|
assertThat(task(child)!!.parentId).isEqualTo(parent)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aRowSelectedWithAnArgumentIsWritten() {
|
||||||
|
val a = insert(Tasks.TITLE to "a")
|
||||||
|
val b = insert(Tasks.TITLE to "b")
|
||||||
|
|
||||||
|
assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), "${Tasks.ID}=?", arrayOf(b.toString()))).isEqualTo(1)
|
||||||
|
assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), "${Tasks.ID} = ?", arrayOf("987654"))).isEqualTo(0)
|
||||||
|
|
||||||
|
assertThat(task(a)!!.title).isEqualTo("a")
|
||||||
|
assertThat(task(b)!!.title).isEqualTo("x")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun garbageExceptionDatesAreRejected() {
|
||||||
|
val series = dailySeries()
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
writer.update(ProviderTable.TASKS, mapOf(Tasks.EXDATE to "tomorrow"), byId(ProviderTable.TASKS, series), null)
|
||||||
|
}
|
||||||
|
assertThat(task(series)!!.exdate).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun store() = RoomTasksDataSource(db)
|
||||||
|
|
||||||
|
/** Every column of one published row, as a client that read it would write it back. */
|
||||||
|
private fun readRow(table: ProviderTable, id: Long): Map<String, Any?> {
|
||||||
|
val columns = table.columns.map { it.first }
|
||||||
|
val row = source.rows(table, columns, byId(table, id), null).single()
|
||||||
|
return columns.zip(row).toMap() - table.idColumn
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private object NoProvider : ProviderEnvironment {
|
||||||
|
override fun packageDeclaring(authority: String): String? = null
|
||||||
|
override fun isGranted(permission: String): Boolean = false
|
||||||
|
override fun appLabel(packageName: String): String? = null
|
||||||
|
}
|
||||||
+141
@@ -0,0 +1,141 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks.provider
|
||||||
|
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
||||||
|
import org.junit.Assert.assertThrows
|
||||||
|
import org.junit.Assume.assumeTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
/** The SQL a client sends runs against the snapshot as it would against OpenTasks. */
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class SnapshotDatabaseTest {
|
||||||
|
|
||||||
|
private val db = SnapshotDatabase()
|
||||||
|
|
||||||
|
private fun task(id: Long, title: String?, due: Long? = null): ProviderRow =
|
||||||
|
mapOf(Tasks.ID to id, Tasks.LIST_ID to 1L, Tasks.TITLE to title, Tasks.DUE to due, Tasks.PRIORITY to 0)
|
||||||
|
|
||||||
|
private fun load(vararg tasks: ProviderRow, lists: List<ProviderRow> = emptyList()) = db.load(
|
||||||
|
ProviderSnapshot(
|
||||||
|
ProviderTable.entries.associateWith {
|
||||||
|
when (it) {
|
||||||
|
ProviderTable.TASKS -> tasks.toList()
|
||||||
|
ProviderTable.LISTS -> lists
|
||||||
|
else -> emptyList()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun Cursor.strings(column: String): List<String?> = use {
|
||||||
|
val index = getColumnIndexOrThrow(column)
|
||||||
|
buildList { while (moveToNext()) add(if (isNull(index)) null else getString(index)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aNullProjectionReturnsEveryColumnInTableOrder() {
|
||||||
|
load(task(1, "a"))
|
||||||
|
|
||||||
|
db.query(ProviderTable.TASKS, null, null, null, null).use {
|
||||||
|
assertThat(it.columnNames.toList()).containsExactlyElementsIn(ProviderTable.TASKS.columns.map { c -> c.first })
|
||||||
|
.inOrder()
|
||||||
|
assertThat(it.count).isEqualTo(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun selectionArgumentsAndSortOrderApply() {
|
||||||
|
load(task(1, "b", due = 30), task(2, "a", due = 10), task(3, "c", due = 20), task(4, "skip", due = 5))
|
||||||
|
|
||||||
|
val titles = db.query(ProviderTable.TASKS, arrayOf(Tasks.TITLE), "${Tasks.TITLE} != ?", arrayOf("skip"), "${Tasks.DUE} ASC")
|
||||||
|
.strings(Tasks.TITLE)
|
||||||
|
|
||||||
|
assertThat(titles).containsExactly("a", "c", "b").inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun typesSurviveTheCopy() {
|
||||||
|
load(task(1, null, due = 1_700_000_000_000))
|
||||||
|
|
||||||
|
db.query(ProviderTable.TASKS, arrayOf(Tasks.TITLE, Tasks.DUE, Tasks.PRIORITY), null, null, null).use {
|
||||||
|
it.moveToFirst()
|
||||||
|
assertThat(it.isNull(0)).isTrue()
|
||||||
|
assertThat(it.getType(1)).isEqualTo(Cursor.FIELD_TYPE_INTEGER)
|
||||||
|
assertThat(it.getLong(1)).isEqualTo(1_700_000_000_000)
|
||||||
|
assertThat(it.getInt(2)).isEqualTo(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anUnsupportedColumnReadsAsNull() {
|
||||||
|
load(task(1, "a"))
|
||||||
|
|
||||||
|
assertThat(db.query(ProviderTable.TASKS, arrayOf("_sync_id"), null, null, null).strings("_sync_id"))
|
||||||
|
.containsExactly(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anUnknownProjectionColumnIsRejected() {
|
||||||
|
load(task(1, "a"))
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
db.query(ProviderTable.TASKS, arrayOf("no_such_column"), null, null, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anUnknownSelectionColumnIsRejected() {
|
||||||
|
assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
|
||||||
|
load(task(1, "a"))
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
db.query(ProviderTable.TASKS, null, "sqlite_master.name IS NOT NULL", null, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aSelectionCannotBreakOutOfItsWhereClause() {
|
||||||
|
load(task(1, "a"))
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
db.query(ProviderTable.TASKS, null, "1) UNION SELECT name FROM sqlite_master --", null, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun eachTableAnswersOnlyForItsOwnRows() {
|
||||||
|
load(task(1, "a"), lists = listOf(mapOf(Lists.ID to 7L, Lists.NAME to "Home")))
|
||||||
|
|
||||||
|
assertThat(db.query(ProviderTable.LISTS, arrayOf(Lists.NAME), null, null, null).strings(Lists.NAME))
|
||||||
|
.containsExactly("Home")
|
||||||
|
db.query(ProviderTable.INSTANCES, null, null, null, null).use { assertThat(it.count).isEqualTo(0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aReloadReplacesTheRowsButNotAnOpenCursor() {
|
||||||
|
load(task(1, "old"))
|
||||||
|
val before = db.query(ProviderTable.TASKS, arrayOf(Tasks.TITLE), null, null, null)
|
||||||
|
|
||||||
|
load(task(2, "new"))
|
||||||
|
|
||||||
|
assertThat(before.strings(Tasks.TITLE)).containsExactly("old")
|
||||||
|
assertThat(db.query(ProviderTable.TASKS, arrayOf(Tasks.TITLE), null, null, null).strings(Tasks.TITLE))
|
||||||
|
.containsExactly("new")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anEmptySnapshotEmptiesEveryTable() {
|
||||||
|
load(task(1, "a"))
|
||||||
|
|
||||||
|
db.load(ProviderSnapshot.EMPTY)
|
||||||
|
|
||||||
|
ProviderTable.entries.forEach { table ->
|
||||||
|
db.query(table, null, null, null, null).use { assertThat(it.count).isEqualTo(0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+182
-29
@@ -5,7 +5,7 @@ import androidx.test.core.app.ApplicationProvider
|
|||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
|
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
|
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
@@ -366,41 +366,23 @@ class RoomTasksDataSourceTest {
|
|||||||
@Test
|
@Test
|
||||||
fun alarmsRoundTripAndReplaceRatherThanAccumulate() {
|
fun alarmsRoundTripAndReplaceRatherThanAccumulate() {
|
||||||
val id = source.insertTask(form(due = now + 1.days))
|
val id = source.insertTask(form(due = now + 1.days))
|
||||||
|
val start = TaskReminder(minutesBefore = 10, fromStart = true)
|
||||||
|
|
||||||
// The whole reminder, not just the minute count: collapsing it to a bare
|
// The whole reminder, not just the minute count: collapsing it to a bare
|
||||||
// Int is what fired an imported START-referenced alarm off DUE, and an
|
// Int is what fired an imported START-referenced alarm off DUE.
|
||||||
// alarm this seam sets from the UI is always due-referenced.
|
source.setReminders(id, listOf(start, TaskReminder(30)))
|
||||||
source.setAlarm(id, 30)
|
|
||||||
assertThat(source.alarms()[id]).isEqualTo(TaskReminder(minutesBefore = 30))
|
|
||||||
|
|
||||||
source.setAlarm(id, 60)
|
|
||||||
assertThat(db.alarms().forTask(id)).hasSize(1)
|
|
||||||
assertThat(source.alarms()[id]).isEqualTo(TaskReminder(minutesBefore = 60))
|
|
||||||
|
|
||||||
source.setAlarm(id, null)
|
|
||||||
assertThat(source.alarms()).doesNotContainKey(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun settingTheEditableReminderLeavesTheOthersAlone() {
|
|
||||||
val id = source.insertTask(form(due = now + 1.days))
|
|
||||||
val start = TaskReminder(minutesBefore = 10, fromStart = true)
|
|
||||||
source.setReminders(id, listOf(start, TaskReminder(30), TaskReminder(120)))
|
|
||||||
|
|
||||||
source.setAlarm(id, 45)
|
|
||||||
assertThat(source.reminders()[id]).containsExactly(start, TaskReminder(30), TaskReminder(45)).inOrder()
|
|
||||||
assertThat(source.alarms()[id]).isEqualTo(TaskReminder(45))
|
|
||||||
|
|
||||||
source.setAlarm(id, null)
|
|
||||||
assertThat(source.reminders()[id]).containsExactly(start, TaskReminder(30)).inOrder()
|
assertThat(source.reminders()[id]).containsExactly(start, TaskReminder(30)).inOrder()
|
||||||
assertThat(source.alarms()[id]).isEqualTo(TaskReminder(30))
|
|
||||||
|
source.setReminders(id, listOf(TaskReminder(60)))
|
||||||
|
assertThat(db.alarms().forTask(id)).hasSize(1)
|
||||||
|
assertThat(source.reminders()[id]).containsExactly(TaskReminder(60))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun forkingAnOccurrenceCarriesTheReminderOntoIt() {
|
fun forkingAnOccurrenceCarriesTheReminderOntoIt() {
|
||||||
val id = source.insertTask(form(due = now + 1.days))
|
val id = source.insertTask(form(due = now + 1.days))
|
||||||
makeRecurring(id, now)
|
makeRecurring(id, now)
|
||||||
source.setAlarm(id, 30)
|
source.setReminders(id, listOf(TaskReminder(30)))
|
||||||
val target = source.tasks(TaskQuery(listId = listId))
|
val target = source.tasks(TaskQuery(listId = listId))
|
||||||
.filter { it.taskId == id }
|
.filter { it.taskId == id }
|
||||||
.first { it.distanceFromCurrent == 1 }
|
.first { it.distanceFromCurrent == 1 }
|
||||||
@@ -411,6 +393,134 @@ class RoomTasksDataSourceTest {
|
|||||||
assertThat(db.alarms().forTask(override.id).single().minutesBefore).isEqualTo(30)
|
assertThat(db.alarms().forTask(override.id).single().minutesBefore).isEqualTo(30)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aSaveKeepsTheIdentityOfAReminderItDidNotChange() {
|
||||||
|
val id = source.insertTask(form(due = now + 1.days), listOf(TaskReminder(15)))
|
||||||
|
val original = db.alarms().forTask(id).single()
|
||||||
|
db.alarms().update(original.copy(raw = "BEGIN:VALARM\r\nX-VENDOR:keep\r\nEND:VALARM\r\n"))
|
||||||
|
|
||||||
|
source.updateTask(id, form(title = "renamed", due = now + 2.days), reminders = listOf(TaskReminder(15), TaskReminder(60)))
|
||||||
|
|
||||||
|
val rows = db.alarms().forTask(id)
|
||||||
|
val kept = rows.single { it.minutesBefore == 15 }
|
||||||
|
// Same row, same identity on the server, same original text.
|
||||||
|
assertThat(kept.id).isEqualTo(original.id)
|
||||||
|
assertThat(kept.uid).isEqualTo(original.uid)
|
||||||
|
assertThat(kept.raw).contains("X-VENDOR:keep")
|
||||||
|
assertThat(rows.single { it.minutesBefore == 60 }.uid).isNotEqualTo(original.uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aSaveWritesBeforeStartAndBeforeDueRemindersAlike() {
|
||||||
|
val id = source.insertTask(form(due = now + 1.days))
|
||||||
|
source.setReminders(id, listOf(TaskReminder(10, fromStart = true), TaskReminder(30)))
|
||||||
|
|
||||||
|
source.updateTask(id, form(due = now + 1.days), reminders = listOf(TaskReminder(5, fromStart = true), TaskReminder(45)))
|
||||||
|
|
||||||
|
assertThat(source.reminders()[id]).containsExactly(TaskReminder(5, fromStart = true), TaskReminder(45))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aReminderForOneOccurrenceLeavesTheSeriesUntouched() {
|
||||||
|
val work = syncedList()
|
||||||
|
val id = source.insertTask(form(due = now + 1.days).copy(listId = work), listOf(TaskReminder(30)))
|
||||||
|
makeRecurring(id, now)
|
||||||
|
db.tasks().update(db.tasks().entity(id)!!.copy(isDirty = false))
|
||||||
|
val target = source.tasks(TaskQuery(listId = work))
|
||||||
|
.filter { it.taskId == id }
|
||||||
|
.first { it.distanceFromCurrent == 1 }
|
||||||
|
|
||||||
|
source.updateInstance(id, target.occurrenceStart!!, form(title = "moved").copy(listId = work), listOf(TaskReminder(60)))
|
||||||
|
|
||||||
|
// ⚠️ The master is never written, not even briefly: a sync landing in
|
||||||
|
// between would upload the occurrence's reminder for the whole series.
|
||||||
|
assertThat(db.alarms().forTask(id).map { it.minutesBefore }).containsExactly(30)
|
||||||
|
assertThat(db.tasks().entity(id)!!.isDirty).isFalse()
|
||||||
|
val override = db.tasks().override(id, target.occurrenceStart)!!
|
||||||
|
assertThat(db.alarms().forTask(override.id).map { it.minutesBefore }).containsExactly(60)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun splittingASeriesGivesTheNewSeriesItsOwnReminderIdentities() {
|
||||||
|
val id = source.insertTask(form(due = now + 1.days), listOf(TaskReminder(30)))
|
||||||
|
makeRecurring(id, now - 14.days)
|
||||||
|
val master = db.alarms().forTask(id).single()
|
||||||
|
val target = source.tasks(TaskQuery(listId = listId))
|
||||||
|
.filter { it.taskId == id }
|
||||||
|
.first { it.distanceFromCurrent == 1 }
|
||||||
|
|
||||||
|
val created = source.splitSeries(id, target.occurrenceStart!!, form(title = "later"), listOf(TaskReminder(30)))
|
||||||
|
|
||||||
|
assertThat(created).isNotEqualTo(id)
|
||||||
|
val copy = db.alarms().forTask(created).single()
|
||||||
|
assertThat(copy.minutesBefore).isEqualTo(30)
|
||||||
|
// A different UID on the server, so the alarm must not share one either.
|
||||||
|
assertThat(copy.uid).isNotEqualTo(master.uid)
|
||||||
|
assertThat(db.alarms().forTask(id).single().uid).isEqualTo(master.uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun changingOnlyTheRemindersOwesTheServerAPut() {
|
||||||
|
val work = syncedList()
|
||||||
|
val id = source.insertTask(form(due = now + 1.days).copy(listId = work))
|
||||||
|
db.tasks().update(db.tasks().entity(id)!!.copy(isDirty = false))
|
||||||
|
|
||||||
|
source.setReminders(id, listOf(TaskReminder(15)))
|
||||||
|
assertThat(db.tasks().entity(id)!!.isDirty).isTrue()
|
||||||
|
|
||||||
|
db.tasks().update(db.tasks().entity(id)!!.copy(isDirty = false))
|
||||||
|
source.setReminders(id, listOf(TaskReminder(15)))
|
||||||
|
// Nothing changed, so nothing is owed.
|
||||||
|
assertThat(db.tasks().entity(id)!!.isDirty).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aSeriesReminderEditReachesTheOccurrencesThatStillHadTheOldOne() {
|
||||||
|
val id = source.insertTask(form(due = now + 1.days), listOf(TaskReminder(15)))
|
||||||
|
makeRecurring(id, now)
|
||||||
|
val occurrences = source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id }
|
||||||
|
val completed = occurrences.first { it.distanceFromCurrent == 1 }.occurrenceStart!!
|
||||||
|
val own = occurrences.first { it.distanceFromCurrent == 2 }.occurrenceStart!!
|
||||||
|
source.setCompletedInstance(id, completed, true)
|
||||||
|
source.updateInstance(id, own, form(title = "own"), listOf(TaskReminder(5)))
|
||||||
|
|
||||||
|
// Same times and rule, so the series is not reshaped — that drops its overrides.
|
||||||
|
val first = occurrences.first()
|
||||||
|
val unchanged = form().copy(start = first.start, due = first.due, rrule = "FREQ=WEEKLY")
|
||||||
|
source.updateSeries(id, first.occurrenceStart!!, unchanged, listOf(TaskReminder(60)))
|
||||||
|
|
||||||
|
fun leads(anchor: Instant) = db.alarms().forTask(db.tasks().override(id, anchor)!!.id).map { it.minutesBefore }
|
||||||
|
assertThat(db.alarms().forTask(id).map { it.minutesBefore }).containsExactly(60)
|
||||||
|
// A snapshot of the series' reminder follows it…
|
||||||
|
assertThat(leads(completed)).containsExactly(60)
|
||||||
|
// …one set for that occurrence alone does not.
|
||||||
|
assertThat(leads(own)).containsExactly(5)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aTaskWhoseAlarmsWereTurnedOffElsewhereReportsNone() {
|
||||||
|
val id = source.insertTask(form(due = now + 1.days))
|
||||||
|
db.alarms().setRemindersOff(id, true)
|
||||||
|
|
||||||
|
assertThat(source.reminders()[id]).isEmpty()
|
||||||
|
assertThat(source.reminders()).containsKey(id)
|
||||||
|
|
||||||
|
// A reminder of its own again: the flag goes.
|
||||||
|
source.setReminders(id, listOf(TaskReminder(15)))
|
||||||
|
assertThat(db.tasks().entity(id)!!.remindersOff).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun settingRemindersOwesTheServerAPut() {
|
||||||
|
val work = syncedList()
|
||||||
|
val id = source.insertTask(form(due = now + 1.days).copy(listId = work))
|
||||||
|
db.tasks().update(db.tasks().entity(id)!!.copy(isDirty = false))
|
||||||
|
|
||||||
|
source.setReminders(id, listOf(TaskReminder(30)))
|
||||||
|
|
||||||
|
assertThat(db.tasks().entity(id)!!.isDirty).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun deletingATaskInALocalListRemovesItOutright() {
|
fun deletingATaskInALocalListRemovesItOutright() {
|
||||||
val id = source.insertTask(form())
|
val id = source.insertTask(form())
|
||||||
@@ -439,7 +549,7 @@ class RoomTasksDataSourceTest {
|
|||||||
fun aForkedOccurrenceCarriesTheSeriesReminder() {
|
fun aForkedOccurrenceCarriesTheSeriesReminder() {
|
||||||
val id = source.insertTask(form())
|
val id = source.insertTask(form())
|
||||||
makeRecurring(id, now)
|
makeRecurring(id, now)
|
||||||
source.setAlarm(id, minutesBeforeDue = 30)
|
source.setReminders(id, listOf(TaskReminder(30)))
|
||||||
val target = source.tasks(TaskQuery(listId = listId))
|
val target = source.tasks(TaskQuery(listId = listId))
|
||||||
.filter { it.taskId == id }
|
.filter { it.taskId == id }
|
||||||
.first { it.distanceFromCurrent == 1 }
|
.first { it.distanceFromCurrent == 1 }
|
||||||
@@ -447,7 +557,7 @@ class RoomTasksDataSourceTest {
|
|||||||
source.updateInstance(id, target.occurrenceStart!!, form(title = "moved"))
|
source.updateInstance(id, target.occurrenceStart!!, form(title = "moved"))
|
||||||
|
|
||||||
// The fork copies the master's properties, which is what carries the
|
// The fork copies the master's properties, which is what carries the
|
||||||
// reminder across — the repository is what puts the series' own back.
|
// reminder across.
|
||||||
val override = db.tasks().override(id, target.occurrenceStart)!!
|
val override = db.tasks().override(id, target.occurrenceStart)!!
|
||||||
assertThat(db.alarms().forTask(override.id).single().minutesBefore).isEqualTo(30)
|
assertThat(db.alarms().forTask(override.id).single().minutesBefore).isEqualTo(30)
|
||||||
}
|
}
|
||||||
@@ -539,4 +649,47 @@ class RoomTasksDataSourceTest {
|
|||||||
|
|
||||||
assertThat(thrown).isNotNull()
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+106
-21
@@ -8,27 +8,7 @@ import org.junit.Rule
|
|||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
/**
|
/** Migrations, proven against the committed schemas in `app/schemas/`. */
|
||||||
* The migration harness, proven against the committed schema in `app/schemas/`.
|
|
||||||
*
|
|
||||||
* There is one schema version today, so all there is to assert is that the helper
|
|
||||||
* can build v1 from the exported JSON, seed it, and validate it back — i.e. the
|
|
||||||
* export, the assets wiring and the identity hash all line up. That is the point:
|
|
||||||
* the first real migration only has to add its own case.
|
|
||||||
*
|
|
||||||
* **Adding a v1 → v2 case.** When sync adds columns, bump [TasksDatabase]'s
|
|
||||||
* `version`, let KSP export `2.json`, declare the `Migration(1, 2)` next to the
|
|
||||||
* database, and add a test here shaped like this:
|
|
||||||
*
|
|
||||||
* ```
|
|
||||||
* helper.createDatabase(TEST_DB, 1).use { db ->
|
|
||||||
* db.execSQL("INSERT INTO task_lists (name, color) VALUES ('Groceries', 0)")
|
|
||||||
* }
|
|
||||||
* helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2).use { db ->
|
|
||||||
* // read the seeded rows back — validation proves the shape, not the data
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
class TasksDatabaseMigrationTest {
|
class TasksDatabaseMigrationTest {
|
||||||
|
|
||||||
@@ -62,6 +42,111 @@ class TasksDatabaseMigrationTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun liftsCategoriesOutOfTheResidueWithoutDirtyingTheTask() {
|
||||||
|
helper.createDatabase(TEST_DB, 1).use { db ->
|
||||||
|
db.execSQL("INSERT INTO task_lists (id, name, color) VALUES (1, 'Work', 0)")
|
||||||
|
db.execSQL(
|
||||||
|
"INSERT INTO tasks (id, list_id, uid, title, unknown_properties) VALUES (?, 1, ?, ?, ?)",
|
||||||
|
arrayOf<Any?>(1, "uid-1", "Claimed", "X-FOO:bar\r\nCATEGORIES:Work,Home\r\n"),
|
||||||
|
)
|
||||||
|
db.execSQL(
|
||||||
|
"INSERT INTO tasks (id, list_id, uid, title, unknown_properties) VALUES (?, 1, ?, ?, ?)",
|
||||||
|
arrayOf<Any?>(2, "uid-2", "Split", "CATEGORIES:a\r\nCATEGORIES:b\r\n"),
|
||||||
|
)
|
||||||
|
db.execSQL("INSERT INTO tasks (id, list_id, uid, title) VALUES (3, 1, 'uid-3', 'Untagged')")
|
||||||
|
}
|
||||||
|
|
||||||
|
helper.runMigrationsAndValidate(TEST_DB, 2, true, TasksDatabase.MIGRATION_1_2).use { db ->
|
||||||
|
db.query("SELECT categories, unknown_properties, is_dirty FROM tasks ORDER BY id").use { c ->
|
||||||
|
c.moveToNext()
|
||||||
|
assertThat(c.getString(0)).isEqualTo("Work,Home")
|
||||||
|
assertThat(c.getString(1)).doesNotContain("CATEGORIES")
|
||||||
|
assertThat(c.getString(1)).contains("X-FOO:bar")
|
||||||
|
assertThat(c.getInt(2)).isEqualTo(0)
|
||||||
|
|
||||||
|
c.moveToNext()
|
||||||
|
assertThat(c.getString(0)).isEqualTo("a,b")
|
||||||
|
assertThat(c.getString(1)).isEqualTo("CATEGORIES:a\r\nCATEGORIES:b\r\n")
|
||||||
|
|
||||||
|
c.moveToNext()
|
||||||
|
assertThat(c.getString(0)).isEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun liftsClaimableAlarmsAndPushesRemindersThatNeverLeftTheDevice() {
|
||||||
|
val future = System.currentTimeMillis() + 7L * 24 * 60 * 60 * 1000
|
||||||
|
val past = System.currentTimeMillis() - 7L * 24 * 60 * 60 * 1000
|
||||||
|
val claimable = "X-FOO:bar\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nX-WR-ALARMUID:srv\r\n" +
|
||||||
|
"TRIGGER;RELATED=END:-PT15M\r\nEND:VALARM\r\n"
|
||||||
|
val appleDefault = "BEGIN:VALARM\r\nACTION:DISPLAY\r\nX-APPLE-DEFAULT-ALARM:TRUE\r\n" +
|
||||||
|
"TRIGGER;RELATED=END:-PT30M\r\nEND:VALARM\r\n"
|
||||||
|
helper.createDatabase(TEST_DB, 2).use { db ->
|
||||||
|
db.execSQL("INSERT INTO accounts (id, display_name) VALUES (1, 'me')")
|
||||||
|
db.execSQL("INSERT INTO task_lists (id, name, color, account_id) VALUES (1, 'Synced', 0, 1)")
|
||||||
|
db.execSQL("INSERT INTO task_lists (id, name, color) VALUES (2, 'Local', 0)")
|
||||||
|
db.execSQL("INSERT INTO task_lists (id, name, color, account_id, is_read_only) VALUES (3, 'Shared', 0, 1, 1)")
|
||||||
|
fun task(id: Int, list: Int, due: Long, residue: String? = null) = db.execSQL(
|
||||||
|
"INSERT INTO tasks (id, list_id, uid, title, due, unknown_properties) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
arrayOf<Any?>(id, list, "uid-$id", "Task $id", due, residue),
|
||||||
|
)
|
||||||
|
fun alarm(task: Int, minutes: Int) =
|
||||||
|
db.execSQL("INSERT INTO task_alarms (task_id, minutes_before) VALUES (?, ?)", arrayOf<Any?>(task, minutes))
|
||||||
|
// A server alarm, and the same lead copied locally by a fork.
|
||||||
|
task(1, 1, future, claimable)
|
||||||
|
alarm(1, 15)
|
||||||
|
// Set in Agendula, never uploaded — still to fire, already past, device-only.
|
||||||
|
task(2, 1, future)
|
||||||
|
alarm(2, 30)
|
||||||
|
task(3, 1, past)
|
||||||
|
alarm(3, 30)
|
||||||
|
task(4, 2, future)
|
||||||
|
alarm(4, 30)
|
||||||
|
// Somebody else's default alarm: not ours to claim.
|
||||||
|
task(5, 1, future, appleDefault)
|
||||||
|
// A read-only share: dirty would freeze it, never sent and never refreshed.
|
||||||
|
task(6, 3, future)
|
||||||
|
alarm(6, 30)
|
||||||
|
}
|
||||||
|
|
||||||
|
helper.runMigrationsAndValidate(TEST_DB, 3, true, TasksDatabase.MIGRATION_2_3).use { db ->
|
||||||
|
db.query("SELECT task_id, minutes_before, uid, raw FROM task_alarms ORDER BY task_id").use { c ->
|
||||||
|
val rows = buildList { while (c.moveToNext()) add(listOf(c.getLong(0), c.getInt(1), c.getString(2), c.getString(3))) }
|
||||||
|
// One for task 1: the lifted copy won and the local duplicate went.
|
||||||
|
assertThat(rows.map { it[0] }).containsExactly(1L, 2L, 3L, 4L, 6L).inOrder()
|
||||||
|
assertThat(rows[0][2]).isEqualTo("srv")
|
||||||
|
assertThat(rows[0][3] as String).contains("TRIGGER;RELATED=END:-PT15M")
|
||||||
|
// Every pre-existing row got an identity of its own, shaped as a UUID.
|
||||||
|
assertThat(rows.drop(1).map { it[2] as String }.toSet()).hasSize(4)
|
||||||
|
rows.drop(1).forEach {
|
||||||
|
assertThat(it[2] as String).matches("[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}")
|
||||||
|
}
|
||||||
|
assertThat(rows.drop(1).all { (it[2] as String).isNotEmpty() && it[3] == null }).isTrue()
|
||||||
|
}
|
||||||
|
db.query("SELECT id, unknown_properties, is_dirty, last_modified, reminders_off FROM tasks ORDER BY id").use { c ->
|
||||||
|
c.moveToNext()
|
||||||
|
assertThat(c.getString(1)).isEqualTo("X-FOO:bar\r\n")
|
||||||
|
assertThat(c.getInt(2)).isEqualTo(0)
|
||||||
|
assertThat(c.getInt(4)).isEqualTo(0)
|
||||||
|
c.moveToNext()
|
||||||
|
assertThat(c.getInt(2)).isEqualTo(1)
|
||||||
|
// The body changes, so its modification stamp does too.
|
||||||
|
assertThat(c.isNull(3)).isFalse()
|
||||||
|
c.moveToNext()
|
||||||
|
assertThat(c.getInt(2)).isEqualTo(0)
|
||||||
|
c.moveToNext()
|
||||||
|
assertThat(c.getInt(2)).isEqualTo(0)
|
||||||
|
c.moveToNext()
|
||||||
|
assertThat(c.getString(1)).contains("X-APPLE-DEFAULT-ALARM:TRUE")
|
||||||
|
assertThat(c.getInt(2)).isEqualTo(0)
|
||||||
|
c.moveToNext()
|
||||||
|
assertThat(c.getInt(2)).isEqualTo(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val TEST_DB = "migration-test.db"
|
const val TEST_DB = "migration-test.db"
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-8
@@ -11,7 +11,7 @@ import com.google.common.truth.Truth.assertThat
|
|||||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderEnvironment
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderEnvironment
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
|
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
|
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
|
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||||
@@ -107,6 +107,17 @@ class ExternalImportTest {
|
|||||||
assertThat(importer.hasRun.first()).isTrue()
|
assertThat(importer.hasRun.first()).isTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun copiesTagsCleanedUp() = runBlocking {
|
||||||
|
source.lists = listOf(list(7, "Errands"))
|
||||||
|
source.tasks = mapOf(7L to listOf(task(100, "Milk", tags = listOf("Shop", " Shop ", "", "Home"))))
|
||||||
|
|
||||||
|
importer.run()
|
||||||
|
|
||||||
|
assertThat(db.tasks().tasks(listId = null, includeCompleted = true).single().task.categories)
|
||||||
|
.containsExactly("Shop", "Home").inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
/** Every list arrives device-only: the account belongs to the sync app. */
|
/** Every list arrives device-only: the account belongs to the sync app. */
|
||||||
@Test
|
@Test
|
||||||
fun importedListsAreDeviceOnly() = runBlocking {
|
fun importedListsAreDeviceOnly() = runBlocking {
|
||||||
@@ -241,6 +252,7 @@ class ExternalImportTest {
|
|||||||
title: String,
|
title: String,
|
||||||
uid: String? = "uid-$id",
|
uid: String? = "uid-$id",
|
||||||
parentId: Long? = null,
|
parentId: Long? = null,
|
||||||
|
tags: List<String> = emptyList(),
|
||||||
) = ExportTask(
|
) = ExportTask(
|
||||||
taskId = id,
|
taskId = id,
|
||||||
uid = uid,
|
uid = uid,
|
||||||
@@ -260,6 +272,7 @@ class ExternalImportTest {
|
|||||||
rrule = null,
|
rrule = null,
|
||||||
rdate = null,
|
rdate = null,
|
||||||
parentId = parentId,
|
parentId = parentId,
|
||||||
|
tags = tags,
|
||||||
)
|
)
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
@@ -281,23 +294,25 @@ private class FakeExternalStore : TasksDataSource {
|
|||||||
return tasks[listId].orEmpty()
|
return tasks[listId].orEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun alarms(): Map<Long, TaskReminder> = alarms
|
override fun reminders(): Map<Long, List<TaskReminder>> = alarms.mapValues { listOf(it.value) }
|
||||||
|
|
||||||
override fun tasks(query: TaskQuery): List<Task> = unused()
|
override fun tasks(query: TaskQuery): List<Task> = unused()
|
||||||
override fun task(taskId: Long): Task? = unused()
|
override fun task(taskId: Long): Task? = unused()
|
||||||
override fun subtasks(parentTaskId: Long): List<Task> = unused()
|
override fun subtasks(parentTaskId: Long): List<Task> = unused()
|
||||||
override fun insertTask(form: TaskForm): Long = unused()
|
override fun insertTask(form: TaskForm, reminders: List<TaskReminder>?): Long = unused()
|
||||||
override fun updateTask(taskId: Long, form: TaskForm) = unused()
|
override fun updateTask(taskId: Long, form: TaskForm, reminders: List<TaskReminder>?) = unused()
|
||||||
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) = unused()
|
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) =
|
||||||
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) = unused()
|
unused()
|
||||||
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) = unused()
|
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) = unused()
|
||||||
override fun setCompleted(taskId: Long, completed: Boolean) = unused()
|
override fun setCompleted(taskId: Long, completed: Boolean) = unused()
|
||||||
override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) = unused()
|
override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) = unused()
|
||||||
override fun deleteTask(taskId: Long) = unused()
|
override fun deleteTask(taskId: Long) = unused()
|
||||||
override fun setCancelled(taskId: Long, cancelled: Boolean) = unused()
|
override fun setCancelled(taskId: Long, cancelled: Boolean) = unused()
|
||||||
override fun setCancelledInstance(taskId: Long, occurrenceStart: Instant, cancelled: Boolean) = unused()
|
override fun setCancelledInstance(taskId: Long, occurrenceStart: Instant, cancelled: Boolean) = unused()
|
||||||
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) = unused()
|
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) =
|
||||||
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm): Long = unused()
|
unused()
|
||||||
|
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?): Long =
|
||||||
|
unused()
|
||||||
override fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant) = unused()
|
override fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant) = unused()
|
||||||
override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) = unused()
|
override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) = unused()
|
||||||
override fun createLocalList(name: String, color: Int): Long = unused()
|
override fun createLocalList(name: String, color: Int): Long = unused()
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- CalDAV sync and UnifiedPush; everything here needs the network. -->
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<!-- CalDAV sync. ACCESS_NETWORK_STATE is merged in by work-runtime anyway,
|
||||||
|
but it shows in F-Droid's permission diff, so declare it deliberately
|
||||||
|
rather than letting it appear from nowhere.
|
||||||
|
|
||||||
|
READ_SYNC_SETTINGS / WRITE_SYNC_SETTINGS are what the ContentResolver
|
||||||
|
sync APIs need. No FOREGROUND_SERVICE: sync is a plain worker, and the
|
||||||
|
dataSync FGS type would bring the Android 15 six-hours-per-24 budget
|
||||||
|
(whose failure mode is a fatal RemoteServiceException) and a Play
|
||||||
|
requirement for a video demo.
|
||||||
|
|
||||||
|
Two more permissions appear in the merged manifest without being
|
||||||
|
declared here, and both come from work-runtime: WAKE_LOCK, and
|
||||||
|
FOREGROUND_SERVICE. The latter is not us taking the FGS route — below
|
||||||
|
API 31 WorkManager implements expedited work with a foreground service,
|
||||||
|
and minSdk is 29, so it is load-bearing for the "Sync now" button.
|
||||||
|
Removing it with tools:node="remove" would break expedited work on
|
||||||
|
exactly the older devices that need it most. Noted because it shows in
|
||||||
|
F-Droid's permission diff and would otherwise look unexplained. -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
|
||||||
|
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
|
||||||
|
|
||||||
|
<queries>
|
||||||
|
<!-- Custom Tabs provider detection. Without this entry it silently finds
|
||||||
|
nothing on API 30+, and the Nextcloud login flow falls back to an
|
||||||
|
external browser for no visible reason. -->
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.support.customtabs.action.CustomTabsService" />
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
|
||||||
|
<application android:networkSecurityConfig="@xml/network_security_config">
|
||||||
|
|
||||||
|
<!-- Sync plumbing. The stub provider exists only to give the sync
|
||||||
|
adapter an authority to register against: Agendula publishes no real
|
||||||
|
ContentProvider since :provider was deleted, and without an authority
|
||||||
|
ContentService.hasAuthorityAccess() makes every ContentResolver sync
|
||||||
|
call a silent no-op at targetSdk >= 34. -->
|
||||||
|
<provider
|
||||||
|
android:name=".data.sync.SyncStubProvider"
|
||||||
|
android:authorities="${applicationId}.sync"
|
||||||
|
android:exported="false"
|
||||||
|
android:syncable="true" />
|
||||||
|
|
||||||
|
<!-- Exported and guarded by ACCOUNT_MANAGER. Note that
|
||||||
|
android.permission.ACCOUNT_AUTHENTICATOR does not exist. -->
|
||||||
|
<service
|
||||||
|
android:name=".data.sync.AuthenticatorService"
|
||||||
|
android:exported="true"
|
||||||
|
android:permission="android.permission.ACCOUNT_MANAGER">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.accounts.AccountAuthenticator" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.accounts.AccountAuthenticator"
|
||||||
|
android:resource="@xml/authenticator" />
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".data.sync.SyncAdapterService"
|
||||||
|
android:exported="true"
|
||||||
|
android:permission="android.permission.BIND_SYNC_ADAPTER">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.content.SyncAdapter" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.content.SyncAdapter"
|
||||||
|
android:resource="@xml/sync_adapter" />
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<!-- UnifiedPush: the connector binds this to deliver endpoints and
|
||||||
|
WebDAV-Push messages. Not exported; the connector's own receiver is
|
||||||
|
what distributors talk to. -->
|
||||||
|
<service
|
||||||
|
android:name=".data.sync.push.AgendulaPushService"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.di
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import dagger.Binds
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import dagger.multibindings.IntoSet
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.AccountCreator
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.AccountRepository
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.CalDavGateway
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.LoginFlowRecord
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.OkHttpCalDavGateway
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.PendingLoginFlowStore
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.RemoteListRepository
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.RemoteLists
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.SyncNoticeNotifier
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.SyncOnEdit
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.SyncRequests
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.SyncTrigger
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.push.PushRegistrar
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.LocalWriteListener
|
||||||
|
import javax.inject.Provider
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/** See [CredentialsDataStore] for why this is a separate file. */
|
||||||
|
private val Context.credentialsDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||||
|
name = CREDENTIALS_DATASTORE,
|
||||||
|
corruptionHandler = replaceCorrupted(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** See [SyncStateDataStore] for why this is a separate file. */
|
||||||
|
private val Context.syncStateDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||||
|
name = SYNC_STATE_DATASTORE,
|
||||||
|
corruptionHandler = replaceCorrupted(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Named here and in `backup_rules.xml` / `data_extraction_rules.xml`, which
|
||||||
|
* exclude `datastore/$CREDENTIALS_DATASTORE.preferences_pb` by this name.
|
||||||
|
*/
|
||||||
|
const val CREDENTIALS_DATASTORE = "agendula_credentials"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Named here and in `backup_rules.xml` / `data_extraction_rules.xml`, which
|
||||||
|
* exclude `datastore/$SYNC_STATE_DATASTORE.preferences_pb` by this name.
|
||||||
|
*/
|
||||||
|
const val SYNC_STATE_DATASTORE = "agendula_sync_state"
|
||||||
|
|
||||||
|
/** CalDAV sync: the `full` flavor's side of the seams `main` declares. */
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
abstract class SyncBindModule {
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindCalDavGateway(impl: OkHttpCalDavGateway): CalDavGateway
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindAccountCreator(impl: AccountRepository): AccountCreator
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindLoginFlowRecord(impl: PendingLoginFlowStore): LoginFlowRecord
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindLocalWriteListener(impl: SyncOnEdit): LocalWriteListener
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindRemoteLists(impl: RemoteListRepository): RemoteLists
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindSyncRequests(impl: SyncTrigger): SyncRequests
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@IntoSet
|
||||||
|
abstract fun bindSyncNoticeChannel(impl: SyncNoticeNotifier): ChannelRefresher
|
||||||
|
}
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object SyncProvideModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
@CredentialsDataStore
|
||||||
|
fun provideCredentialsDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
||||||
|
context.credentialsDataStore
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
@SyncStateDataStore
|
||||||
|
fun provideSyncStateDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
||||||
|
context.syncStateDataStore
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync hard on app open: the periodic worker's interval is a floor, and in
|
||||||
|
* the `rare` and `restricted` App Standby buckets it may not have run at
|
||||||
|
* all. `KEEP` makes rescheduling idempotent, so this also repairs a schedule
|
||||||
|
* lost to "clear app data" or to a restore.
|
||||||
|
*/
|
||||||
|
@Provides
|
||||||
|
@IntoSet
|
||||||
|
fun syncOnOpenHook(
|
||||||
|
accounts: Provider<AccountRepository>,
|
||||||
|
syncTrigger: Provider<SyncTrigger>,
|
||||||
|
pendingLoginFlows: Provider<PendingLoginFlowStore>,
|
||||||
|
push: Provider<PushRegistrar>,
|
||||||
|
): LaunchHook = LaunchHook {
|
||||||
|
runCatching {
|
||||||
|
accounts.get().rescheduleAll()
|
||||||
|
accounts.get().syncable().forEach { syncTrigger.get().enqueue(it.displayName) }
|
||||||
|
// A login flow the previous process died in the middle of.
|
||||||
|
// Its password, if the user approved, exists nowhere else.
|
||||||
|
pendingLoginFlows.get().reclaim()
|
||||||
|
}
|
||||||
|
// Last and on its own: it waits on the network, and must not hold up
|
||||||
|
// the reclaim above. Re-registering on open is what the connector
|
||||||
|
// recommends.
|
||||||
|
runCatching { push.get().updateAll() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.di
|
||||||
|
|
||||||
|
import javax.inject.Qualifier
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks the DataStore holding **only** the Keystore-encrypted app passwords.
|
||||||
|
*
|
||||||
|
* A separate file from `agendula_prefs` on purpose: Auto Backup includes
|
||||||
|
* `datastore/`, and a restored ciphertext is permanently undecryptable because
|
||||||
|
* Keystore keys are non-exportable. Its own file is what lets the backup rules
|
||||||
|
* exclude the credentials and nothing else — excluding the whole database or
|
||||||
|
* all of DataStore would trade a latent bug for a live one.
|
||||||
|
*/
|
||||||
|
@Qualifier
|
||||||
|
@Retention(AnnotationRetention.BINARY)
|
||||||
|
annotation class CredentialsDataStore
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks the DataStore holding per-device **sync bookkeeping** — the quarantine
|
||||||
|
* counters and the full-reconciliation clock.
|
||||||
|
*
|
||||||
|
* Its own file for the same reason the credentials have one: Auto Backup
|
||||||
|
* includes `datastore/`, and every value in here is a statement about *this*
|
||||||
|
* device's conversation with a server. Restored onto a new install they are all
|
||||||
|
* lies, and two of them are dangerous — a restored "reconciled recently" makes
|
||||||
|
* the engine trust a sync token for another day, which is precisely the silently
|
||||||
|
* pruned change log the full path exists to catch, and a restored quarantine
|
||||||
|
* count silently skips resources that were never tried here.
|
||||||
|
*
|
||||||
|
* Not user data, so nothing is lost by excluding it.
|
||||||
|
*/
|
||||||
|
@Qualifier
|
||||||
|
@Retention(AnnotationRetention.BINARY)
|
||||||
|
annotation class SyncStateDataStore
|
||||||
+46
-9
@@ -1,8 +1,10 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.sync
|
package de.jeanlucmakiola.agendula.data.sync
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.ical.AlarmCodec
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.ical.CalendarResource
|
import de.jeanlucmakiola.agendula.data.tasks.ical.CalendarResource
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.ical.ResourceValidator
|
import de.jeanlucmakiola.agendula.data.tasks.ical.ResourceValidator
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.ical.VTodoMapper
|
import de.jeanlucmakiola.agendula.data.tasks.ical.VTodoMapper
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
|
||||||
import at.bitfire.dav4jvm.exception.UnauthorizedException
|
import at.bitfire.dav4jvm.exception.UnauthorizedException
|
||||||
@@ -422,8 +424,9 @@ class CollectionSyncer(
|
|||||||
|
|
||||||
/** Null when the resource was rejected before it left the device. */
|
/** Null when the resource was rejected before it left the device. */
|
||||||
private fun serialize(local: LocalResource): String? {
|
private fun serialize(local: LocalResource): String? {
|
||||||
|
val alarms = store.alarmsFor(local.live.map { it.id })
|
||||||
val todos = local.live.map { row ->
|
val todos = local.live.map { row ->
|
||||||
VTodoMapper.write(row, parentUidOf(row), now())
|
VTodoMapper.write(row, parentUidOf(row), now(), alarms[row.id].orEmpty())
|
||||||
}
|
}
|
||||||
val calendar = CalendarResource.build(todos)
|
val calendar = CalendarResource.build(todos)
|
||||||
|
|
||||||
@@ -778,14 +781,17 @@ class CollectionSyncer(
|
|||||||
val losing = existing.values.firstOrNull { it.isDirty }
|
val losing = existing.values.firstOrNull { it.isDirty }
|
||||||
|
|
||||||
val master = mapped.firstOrNull { it.entity.recurrenceId == null } ?: mapped.first()
|
val master = mapped.firstOrNull { it.entity.recurrenceId == null } ?: mapped.first()
|
||||||
val masterRow = upsert(master.entity, existing[master.entity.recurrenceId], null, href, eTag)
|
val masterRow = upsert(master, existing[master.entity.recurrenceId], null, href, eTag)
|
||||||
val masterId = masterRow.id
|
val masterId = masterRow.id
|
||||||
parents[uid] = master.parentUid
|
parents[uid] = master.parentUid
|
||||||
|
|
||||||
val written = mutableListOf(masterRow)
|
val written = mutableListOf(masterRow)
|
||||||
mapped.filter { it !== master }.forEach { override ->
|
mapped.filter { it !== master }.forEach { override ->
|
||||||
|
// ⚠️ An override's alarms are its own: RFC 5545 has an override
|
||||||
|
// replace the component, so one the server sends without a
|
||||||
|
// VALARM has none, whatever its master carries.
|
||||||
written += upsert(
|
written += upsert(
|
||||||
override.entity, existing[override.entity.recurrenceId], masterId, href, eTag,
|
override, existing[override.entity.recurrenceId], masterId, href, eTag,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val kept = written.map { it.recurrenceId }.toSet()
|
val kept = written.map { it.recurrenceId }.toSet()
|
||||||
@@ -832,12 +838,13 @@ class CollectionSyncer(
|
|||||||
|
|
||||||
/** @return the row as it now stands, so the caller can index it. */
|
/** @return the row as it now stands, so the caller can index it. */
|
||||||
private fun upsert(
|
private fun upsert(
|
||||||
incoming: TaskEntity,
|
mapped: VTodoMapper.Mapped,
|
||||||
existing: TaskEntity?,
|
existing: TaskEntity?,
|
||||||
masterId: Long?,
|
masterId: Long?,
|
||||||
href: String,
|
href: String,
|
||||||
eTag: String?,
|
eTag: String?,
|
||||||
): TaskEntity {
|
): TaskEntity {
|
||||||
|
val incoming = mapped.entity
|
||||||
// Local-only columns the server has no opinion about. Taking the
|
// Local-only columns the server has no opinion about. Taking the
|
||||||
// mapper's defaults here would silently reset the user's ordering and
|
// mapper's defaults here would silently reset the user's ordering and
|
||||||
// per-task colour on every download.
|
// per-task colour on every download.
|
||||||
@@ -854,12 +861,42 @@ class CollectionSyncer(
|
|||||||
// set uploads what was just downloaded.
|
// set uploads what was just downloaded.
|
||||||
isDirty = false,
|
isDirty = false,
|
||||||
isDeleted = false,
|
isDeleted = false,
|
||||||
|
// The store's to change, from what the alarms do.
|
||||||
|
remindersOff = existing?.remindersOff ?: false,
|
||||||
)
|
)
|
||||||
return if (existing == null) {
|
// The server's alarms replace ours, as every other field does: a
|
||||||
row.copy(id = store.insert(row))
|
// dirty row is never downloaded except to discard its edit.
|
||||||
} else {
|
val alarms = existing?.let { keepLocalPrecision(mapped.alarms, it, row) } ?: mapped.alarms
|
||||||
store.update(row)
|
return row.copy(id = store.upsertWithAlarms(row, alarms))
|
||||||
row
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [incoming] with each alarm we uploaded ourselves put back to the lead
|
||||||
|
* we hold for it.
|
||||||
|
*
|
||||||
|
* ⚠️ The wire is coarser than the row: a lead past the end of its
|
||||||
|
* anchor is clamped, and a before-start reminder on a task without a
|
||||||
|
* start goes out before-due. Taking the round trip at its word would
|
||||||
|
* quietly turn a before-start reminder into a before-due one the first
|
||||||
|
* time the task is downloaded again.
|
||||||
|
* An alarm that comes back as exactly what we would have sent has not
|
||||||
|
* been changed, so the row's own lead stands.
|
||||||
|
*/
|
||||||
|
private fun keepLocalPrecision(
|
||||||
|
incoming: List<TaskAlarmEntity>,
|
||||||
|
existing: TaskEntity,
|
||||||
|
task: TaskEntity,
|
||||||
|
): List<TaskAlarmEntity> {
|
||||||
|
val local = store.alarmsFor(listOf(existing.id))[existing.id].orEmpty()
|
||||||
|
if (local.isEmpty()) return incoming
|
||||||
|
return incoming.map { alarm ->
|
||||||
|
val mine = local.firstOrNull { it.uid == alarm.uid } ?: return@map alarm
|
||||||
|
val sent = AlarmCodec.wireLead(mine, task)
|
||||||
|
if (sent == AlarmCodec.Lead(alarm.minutesBefore, alarm.reference)) {
|
||||||
|
alarm.copy(minutesBefore = mine.minutesBefore, reference = mine.reference)
|
||||||
|
} else {
|
||||||
|
alarm
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
+6
-36
@@ -1,6 +1,7 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.sync
|
package de.jeanlucmakiola.agendula.data.sync
|
||||||
|
|
||||||
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.RemoteLists.Outcome
|
||||||
import de.jeanlucmakiola.agendula.data.sync.push.PushStore
|
import de.jeanlucmakiola.agendula.data.sync.push.PushStore
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
|
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
|
||||||
@@ -53,38 +54,7 @@ class RemoteListRepository @Inject constructor(
|
|||||||
private val resolver: ProviderResolver,
|
private val resolver: ProviderResolver,
|
||||||
private val push: PushStore,
|
private val push: PushStore,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) {
|
) : RemoteLists {
|
||||||
|
|
||||||
/** Why a collection write did not happen, in a form the UI can translate. */
|
|
||||||
sealed interface Outcome {
|
|
||||||
data object Done : Outcome
|
|
||||||
|
|
||||||
/** The server said no, and will say no again. */
|
|
||||||
data class Refused(val code: Int) : Outcome
|
|
||||||
|
|
||||||
/** The server could not be reached. Worth another try. */
|
|
||||||
data object Unreachable : Outcome
|
|
||||||
|
|
||||||
/** This account cannot make collections at all — iCloud, Posteo, Google. */
|
|
||||||
data object Unsupported : Outcome
|
|
||||||
|
|
||||||
/** Ours is a read-only share; the write belongs to whoever owns it. */
|
|
||||||
data object ReadOnly : Outcome
|
|
||||||
|
|
||||||
/** The account is gone, stopped, or has no credential we can decrypt. */
|
|
||||||
data object NoAccount : Outcome
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The server answered something this call cannot make sense of.
|
|
||||||
*
|
|
||||||
* ⚠️ Not [Unreachable]. `CollectionOutcome` is one type across create,
|
|
||||||
* update and delete, so each of them has branches the other's method
|
|
||||||
* can return and its own cannot — and mapping those to [Unreachable]
|
|
||||||
* told someone sitting on wifi that they were offline. Unreachable is a
|
|
||||||
* claim about the network, and this is not one.
|
|
||||||
*/
|
|
||||||
data object Unexpected : Outcome
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The accounts a new list may be created on, freshest answer first.
|
* The accounts a new list may be created on, freshest answer first.
|
||||||
@@ -95,7 +65,7 @@ class RemoteListRepository @Inject constructor(
|
|||||||
* able to say so, and the only moment that costs nothing is while the user
|
* able to say so, and the only moment that costs nothing is while the user
|
||||||
* is looking at the picker.
|
* is looking at the picker.
|
||||||
*/
|
*/
|
||||||
suspend fun creatableAccounts(): List<AccountEntity> = withContext(io) {
|
override suspend fun creatableAccounts(): List<AccountEntity> = withContext(io) {
|
||||||
// ⚠️ Empty in External mode, whatever the accounts table holds. The
|
// ⚠️ Empty in External mode, whatever the accounts table holds. The
|
||||||
// lists on screen then come from a third-party provider, so a row
|
// lists on screen then come from a third-party provider, so a row
|
||||||
// inserted into ours would exist, sync, and be visible to nobody.
|
// inserted into ours would exist, sync, and be visible to nobody.
|
||||||
@@ -120,7 +90,7 @@ class RemoteListRepository @Inject constructor(
|
|||||||
*
|
*
|
||||||
* @return the new list's local id, or why there is none.
|
* @return the new list's local id, or why there is none.
|
||||||
*/
|
*/
|
||||||
suspend fun create(
|
override suspend fun create(
|
||||||
accountId: Long,
|
accountId: Long,
|
||||||
name: String,
|
name: String,
|
||||||
color: Int,
|
color: Int,
|
||||||
@@ -189,7 +159,7 @@ class RemoteListRepository @Inject constructor(
|
|||||||
* a row that has already been renamed locally by then reads as a rename that
|
* a row that has already been renamed locally by then reads as a rename that
|
||||||
* worked and then quietly reverted on the next sync.
|
* worked and then quietly reverted on the next sync.
|
||||||
*/
|
*/
|
||||||
suspend fun rename(listId: Long, name: String, color: Int): Outcome = withContext(io) {
|
override suspend fun rename(listId: Long, name: String, color: Int): Outcome = withContext(io) {
|
||||||
val list = database.taskLists().entity(listId) ?: return@withContext Outcome.NoAccount
|
val list = database.taskLists().entity(listId) ?: return@withContext Outcome.NoAccount
|
||||||
if (list.isReadOnly) return@withContext Outcome.ReadOnly
|
if (list.isReadOnly) return@withContext Outcome.ReadOnly
|
||||||
val url = list.href?.toHttpUrlOrNull() ?: return@withContext Outcome.NoAccount
|
val url = list.href?.toHttpUrlOrNull() ?: return@withContext Outcome.NoAccount
|
||||||
@@ -227,7 +197,7 @@ class RemoteListRepository @Inject constructor(
|
|||||||
* grades 404 and 410 that way — because otherwise a collection someone
|
* grades 404 and 410 that way — because otherwise a collection someone
|
||||||
* removed from another client leaves a row here that nothing can get rid of.
|
* removed from another client leaves a row here that nothing can get rid of.
|
||||||
*/
|
*/
|
||||||
suspend fun delete(listId: Long): Outcome = withContext(io) {
|
override suspend fun delete(listId: Long): Outcome = withContext(io) {
|
||||||
val list = database.taskLists().entity(listId) ?: return@withContext Outcome.Done
|
val list = database.taskLists().entity(listId) ?: return@withContext Outcome.Done
|
||||||
if (list.isReadOnly) return@withContext Outcome.ReadOnly
|
if (list.isReadOnly) return@withContext Outcome.ReadOnly
|
||||||
val url = list.href?.toHttpUrlOrNull() ?: return@withContext Outcome.NoAccount
|
val url = list.href?.toHttpUrlOrNull() ?: return@withContext Outcome.NoAccount
|
||||||
+4
-6
@@ -14,6 +14,7 @@ import androidx.core.app.NotificationManagerCompat
|
|||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.jeanlucmakiola.agendula.MainActivity
|
import de.jeanlucmakiola.agendula.MainActivity
|
||||||
|
import de.jeanlucmakiola.agendula.data.di.ChannelRefresher
|
||||||
import de.jeanlucmakiola.agendula.R
|
import de.jeanlucmakiola.agendula.R
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -33,7 +34,7 @@ import javax.inject.Singleton
|
|||||||
@Singleton
|
@Singleton
|
||||||
class SyncNoticeNotifier @Inject constructor(
|
class SyncNoticeNotifier @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
) {
|
) : ChannelRefresher {
|
||||||
|
|
||||||
fun canPost(): Boolean {
|
fun canPost(): Boolean {
|
||||||
val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||||
@@ -123,7 +124,7 @@ class SyncNoticeNotifier @Inject constructor(
|
|||||||
* extra names the account for routing to Settings → Accounts → it.
|
* extra names the account for routing to Settings → Accounts → it.
|
||||||
*/
|
*/
|
||||||
private fun signInIntent(accountId: Long): Intent =
|
private fun signInIntent(accountId: Long): Intent =
|
||||||
MainActivity.openIntent(context).putExtra(EXTRA_SIGN_IN_ACCOUNT_ID, accountId)
|
MainActivity.openIntent(context).putExtra(MainActivity.EXTRA_SIGN_IN_ACCOUNT_ID, accountId)
|
||||||
|
|
||||||
private fun ensureSignInChannel() {
|
private fun ensureSignInChannel() {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
@@ -170,16 +171,13 @@ class SyncNoticeNotifier @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Re-create the channel, if it exists, in the current language. */
|
/** Re-create the channel, if it exists, in the current language. */
|
||||||
fun refreshChannel() {
|
override fun refreshChannel() {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
val manager = context.getSystemService(NotificationManager::class.java)
|
val manager = context.getSystemService(NotificationManager::class.java)
|
||||||
if (manager.getNotificationChannel(CHANNEL_ID) != null) ensureChannel()
|
if (manager.getNotificationChannel(CHANNEL_ID) != null) ensureChannel()
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** The account a sign-in notification is about, on the intent it opens. */
|
|
||||||
const val EXTRA_SIGN_IN_ACCOUNT_ID = "de.jeanlucmakiola.agendula.extra.SIGN_IN_ACCOUNT_ID"
|
|
||||||
|
|
||||||
private const val CHANNEL_ID = "sync_notices"
|
private const val CHANNEL_ID = "sync_notices"
|
||||||
private const val NOTIFICATION_ID = 2
|
private const val NOTIFICATION_ID = 2
|
||||||
private const val SIGN_IN_CHANNEL_ID = "account_sign_in"
|
private const val SIGN_IN_CHANNEL_ID = "account_sign_in"
|
||||||
+36
-5
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.sync
|
package de.jeanlucmakiola.agendula.data.sync
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -11,17 +12,33 @@ import javax.inject.Inject
|
|||||||
* discovery: the reconciliation above this interface is where local edits are
|
* discovery: the reconciliation above this interface is where local edits are
|
||||||
* discarded, tombstones swept and conflicts resolved, and every one of those is
|
* discarded, tombstones swept and conflicts resolved, and every one of those is
|
||||||
* a decision that should be provable without a device. Room's test double is
|
* a decision that should be provable without a device. Room's test double is
|
||||||
* Robolectric plus an in-memory database; this is eight methods.
|
* Robolectric plus an in-memory database; this is a dozen methods.
|
||||||
*/
|
*/
|
||||||
interface SyncStore {
|
interface SyncStore {
|
||||||
|
|
||||||
/** Every row in a list, **tombstones included**. */
|
/** Every row in a list, **tombstones included**. */
|
||||||
fun rowsIn(listId: Long): List<TaskEntity>
|
fun rowsIn(listId: Long): List<TaskEntity>
|
||||||
|
|
||||||
fun insert(row: TaskEntity): Long
|
|
||||||
|
|
||||||
fun update(row: TaskEntity)
|
fun update(row: TaskEntity)
|
||||||
|
|
||||||
|
/** The reminders of [taskIds] — one resource's rows — by task id, for the body that uploads them. */
|
||||||
|
fun alarmsFor(taskIds: List<Long>): Map<Long, List<TaskAlarmEntity>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a downloaded row — inserted when its id is 0 — and makes its
|
||||||
|
* reminders exactly [alarms], in one transaction.
|
||||||
|
*
|
||||||
|
* ⚠️ One transaction, not an [update] and a second call. The row lands clean
|
||||||
|
* with the server's ETag, so if the process died between the two, nothing
|
||||||
|
* would ever download it again and the reminders would stay stale for good.
|
||||||
|
*
|
||||||
|
* A row that is gone by the time it is written — deleted on the device
|
||||||
|
* while the sync ran — stays gone, reminders and all.
|
||||||
|
*
|
||||||
|
* @return the row's id.
|
||||||
|
*/
|
||||||
|
fun upsertWithAlarms(row: TaskEntity, alarms: List<TaskAlarmEntity>): Long
|
||||||
|
|
||||||
fun deleteAll(taskIds: List<Long>)
|
fun deleteAll(taskIds: List<Long>)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,12 +73,26 @@ class RoomSyncStore @Inject constructor(
|
|||||||
|
|
||||||
override fun rowsIn(listId: Long) = database.tasks().allIn(listId)
|
override fun rowsIn(listId: Long) = database.tasks().allIn(listId)
|
||||||
|
|
||||||
override fun insert(row: TaskEntity) = database.tasks().insert(row)
|
|
||||||
|
|
||||||
override fun update(row: TaskEntity) {
|
override fun update(row: TaskEntity) {
|
||||||
database.tasks().update(row)
|
database.tasks().update(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun alarmsFor(taskIds: List<Long>) =
|
||||||
|
if (taskIds.isEmpty()) emptyMap() else database.alarms().forTasks(taskIds).groupBy { it.taskId }
|
||||||
|
|
||||||
|
override fun upsertWithAlarms(row: TaskEntity, alarms: List<TaskAlarmEntity>): Long =
|
||||||
|
database.runInTransaction<Long> {
|
||||||
|
if (row.id == 0L) {
|
||||||
|
database.tasks().insert(row).also { database.alarms().replaceFromServer(it, alarms) }
|
||||||
|
} else {
|
||||||
|
// ⚠️ Zero rows means the row was deleted under us. Its alarms
|
||||||
|
// would then name a task that does not exist, and the foreign
|
||||||
|
// key would fail the whole transaction.
|
||||||
|
if (database.tasks().update(row) > 0) database.alarms().replaceFromServer(row.id, alarms)
|
||||||
|
row.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun deleteAll(taskIds: List<Long>) {
|
override fun deleteAll(taskIds: List<Long>) {
|
||||||
if (taskIds.isNotEmpty()) database.tasks().deleteAll(taskIds)
|
if (taskIds.isNotEmpty()) database.tasks().deleteAll(taskIds)
|
||||||
}
|
}
|
||||||
+5
-1
@@ -35,7 +35,11 @@ import kotlin.time.Duration.Companion.seconds
|
|||||||
@Singleton
|
@Singleton
|
||||||
class SyncTrigger @Inject constructor(
|
class SyncTrigger @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
) {
|
) : SyncRequests {
|
||||||
|
|
||||||
|
override fun syncNow(accountName: String) {
|
||||||
|
enqueue(accountName)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts a sync now.
|
* Starts a sync now.
|
||||||
+2
-1
@@ -70,6 +70,7 @@ internal fun AccountDetailScreen(
|
|||||||
onSignInAgain: (AccountEntity) -> Unit,
|
onSignInAgain: (AccountEntity) -> Unit,
|
||||||
onOpenStorage: () -> Unit,
|
onOpenStorage: () -> Unit,
|
||||||
viewModel: AccountsViewModel,
|
viewModel: AccountsViewModel,
|
||||||
|
visible: Boolean,
|
||||||
) {
|
) {
|
||||||
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
|
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
|
||||||
val usable by viewModel.accountsUsable.collectAsStateWithLifecycle()
|
val usable by viewModel.accountsUsable.collectAsStateWithLifecycle()
|
||||||
@@ -90,7 +91,7 @@ internal fun AccountDetailScreen(
|
|||||||
// shown on the accounts list rather than here.
|
// shown on the accounts list rather than here.
|
||||||
var confirming by remember { mutableStateOf(false) }
|
var confirming by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
CollapsingScaffold(title = identity.title, onBack = onBack) {
|
CollapsingScaffold(title = identity.title, onBack = onBack, predictiveBack = true, visible = visible) {
|
||||||
AccountHero(identity)
|
AccountHero(identity)
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
+3
@@ -48,6 +48,7 @@ internal fun AccountsScreen(
|
|||||||
onOpenStorage: () -> Unit,
|
onOpenStorage: () -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
viewModel: AccountsViewModel,
|
viewModel: AccountsViewModel,
|
||||||
|
visible: Boolean,
|
||||||
) {
|
) {
|
||||||
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
|
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
|
||||||
val usable by viewModel.accountsUsable.collectAsStateWithLifecycle()
|
val usable by viewModel.accountsUsable.collectAsStateWithLifecycle()
|
||||||
@@ -60,6 +61,8 @@ internal fun AccountsScreen(
|
|||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.settings_section_accounts),
|
title = stringResource(R.string.settings_section_accounts),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
|
predictiveBack = true,
|
||||||
|
visible = visible,
|
||||||
) {
|
) {
|
||||||
val loaded = accounts ?: return@CollapsingScaffold
|
val loaded = accounts ?: return@CollapsingScaffold
|
||||||
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.ui.onboarding
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import de.jeanlucmakiola.agendula.ui.accounts.add.ADD_ACCOUNT_STEPS
|
||||||
|
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountScreen
|
||||||
|
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountViewModel
|
||||||
|
|
||||||
|
/** The inline wizard's shortest length, before a provider is picked. */
|
||||||
|
internal const val ACCOUNT_STEP_SLOTS = ADD_ACCOUNT_STEPS
|
||||||
|
|
||||||
|
/** [OnboardingStep.Account]: the add-account wizard, given the whole screen. */
|
||||||
|
@Composable
|
||||||
|
internal fun AccountStep(state: OnboardingUiState, viewModel: OnboardingViewModel) {
|
||||||
|
// The same instance the wizard resolves for itself, so its length can be
|
||||||
|
// read here without keeping a second copy of its state.
|
||||||
|
val account: AddAccountViewModel = hiltViewModel()
|
||||||
|
val accountState by account.state.collectAsStateWithLifecycle()
|
||||||
|
// ⚠️ The wizard grows a step for a provider that needs an app password
|
||||||
|
// minted first, and the outer bar has to grow with it — otherwise first
|
||||||
|
// run counts one flow while the screen inside it counts another.
|
||||||
|
LaunchedEffect(accountState.totalSteps) {
|
||||||
|
viewModel.onAccountStepsChanged(accountState.totalSteps)
|
||||||
|
}
|
||||||
|
AddAccountScreen(
|
||||||
|
onDone = { viewModel.onAccountFinished(added = true) },
|
||||||
|
onBack = { viewModel.onAccountFinished(added = false) },
|
||||||
|
stepOffset = state.position - 1,
|
||||||
|
totalSteps = state.total,
|
||||||
|
viewModel = account,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.ui.settings
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
|
||||||
|
import de.jeanlucmakiola.agendula.ui.accounts.AccountDetailScreen
|
||||||
|
import de.jeanlucmakiola.agendula.ui.accounts.AccountsScreen
|
||||||
|
import de.jeanlucmakiola.agendula.ui.accounts.AccountsViewModel
|
||||||
|
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountScreen
|
||||||
|
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountViewModel
|
||||||
|
|
||||||
|
/** Settings → Accounts and the screens under it, layered over the hub. */
|
||||||
|
@Composable
|
||||||
|
internal fun AccountSections(
|
||||||
|
section: SettingsSection?,
|
||||||
|
initialAccountId: Long?,
|
||||||
|
onSection: (SettingsSection?) -> Unit,
|
||||||
|
) {
|
||||||
|
// Hoisted so the add flow can refresh the list it returns to.
|
||||||
|
val accountsViewModel: AccountsViewModel = hiltViewModel()
|
||||||
|
// Shared with the add flow's own lookup, so "sign in again" can prefill it.
|
||||||
|
val addAccountViewModel: AddAccountViewModel = hiltViewModel()
|
||||||
|
val signInAgain: (AccountEntity) -> Unit = { account ->
|
||||||
|
addAccountViewModel.startReauthentication(account.id, account.principalUrl, account.username)
|
||||||
|
onSection(SettingsSection.AddAccount)
|
||||||
|
}
|
||||||
|
// Which account the detail screen is showing; the section alone cannot say.
|
||||||
|
var openAccount by rememberSaveable(initialAccountId) { mutableStateOf(initialAccountId) }
|
||||||
|
|
||||||
|
// Accounts stays composed under Add account, for the same reason Backup
|
||||||
|
// stays composed under export and import: the deeper screen slides over it.
|
||||||
|
val accountsOpen = section == SettingsSection.Accounts ||
|
||||||
|
section?.parent == SettingsSection.Accounts
|
||||||
|
SlideInSection(visible = accountsOpen) {
|
||||||
|
AccountsScreen(
|
||||||
|
onAddAccount = {
|
||||||
|
// A sign-in abandoned half-way must not turn this into one.
|
||||||
|
if (addAccountViewModel.state.value.reauthenticating) addAccountViewModel.onStartOver()
|
||||||
|
onSection(SettingsSection.AddAccount)
|
||||||
|
},
|
||||||
|
onSignInAgain = signInAgain,
|
||||||
|
onOpenAccount = {
|
||||||
|
openAccount = it
|
||||||
|
onSection(SettingsSection.Account)
|
||||||
|
},
|
||||||
|
onOpenStorage = { onSection(SettingsSection.Storage) },
|
||||||
|
onBack = { onSection(null) },
|
||||||
|
viewModel = accountsViewModel,
|
||||||
|
visible = accountsOpen,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val accountOpen = section == SettingsSection.Account
|
||||||
|
SlideInSection(visible = accountOpen) {
|
||||||
|
openAccount?.let { id ->
|
||||||
|
AccountDetailScreen(
|
||||||
|
accountId = id,
|
||||||
|
onBack = { onSection(SettingsSection.Accounts) },
|
||||||
|
onRemoved = { onSection(SettingsSection.Accounts) },
|
||||||
|
onSignInAgain = signInAgain,
|
||||||
|
onOpenStorage = { onSection(SettingsSection.Storage) },
|
||||||
|
viewModel = accountsViewModel,
|
||||||
|
visible = accountOpen,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SlideInSection(visible = section == SettingsSection.AddAccount) {
|
||||||
|
AddAccountScreen(
|
||||||
|
// The list stays composed underneath, so nothing re-runs its
|
||||||
|
// init and OnResume never fires on a section change. It no longer
|
||||||
|
// needs to: the accounts come from an observed query, so a new
|
||||||
|
// account — and every later sync — arrives on its own.
|
||||||
|
onDone = { onSection(SettingsSection.Accounts) },
|
||||||
|
onBack = { onSection(SettingsSection.Accounts) },
|
||||||
|
viewModel = addAccountViewModel,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,35 +9,25 @@
|
|||||||
selected External mode. Both are dangerous-level.
|
selected External mode. Both are dangerous-level.
|
||||||
|
|
||||||
StorageMode.OWN needs nothing here: it is a Room database in our own data
|
StorageMode.OWN needs nothing here: it is a Room database in our own data
|
||||||
directory. Agendula publishes no ContentProvider and declares no
|
directory. -->
|
||||||
permissions of its own. -->
|
|
||||||
<uses-permission android:name="org.dmfs.permission.READ_TASKS" />
|
<uses-permission android:name="org.dmfs.permission.READ_TASKS" />
|
||||||
<uses-permission android:name="org.dmfs.permission.WRITE_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.READ_TASKS" />
|
||||||
<uses-permission android:name="org.tasks.permission.WRITE_TASKS" />
|
<uses-permission android:name="org.tasks.permission.WRITE_TASKS" />
|
||||||
|
|
||||||
<!-- CalDAV sync. ACCESS_NETWORK_STATE is merged in by work-runtime anyway,
|
<!-- What another app requests to read or write the own store through
|
||||||
but it shows in F-Droid's permission diff, so declare it deliberately
|
AgendulaTaskProvider. Dangerous, like OpenTasks' own: the user grants
|
||||||
rather than letting it appear from nowhere.
|
them per app. Suffixed per build, so debug and release can coexist. -->
|
||||||
|
<permission
|
||||||
READ_SYNC_SETTINGS / WRITE_SYNC_SETTINGS are what the ContentResolver
|
android:name="${applicationId}.permission.READ_TASKS"
|
||||||
sync APIs need. No FOREGROUND_SERVICE: sync is a plain worker, and the
|
android:description="@string/permission_read_tasks_description"
|
||||||
dataSync FGS type would bring the Android 15 six-hours-per-24 budget
|
android:label="@string/permission_read_tasks_label"
|
||||||
(whose failure mode is a fatal RemoteServiceException) and a Play
|
android:protectionLevel="dangerous" />
|
||||||
requirement for a video demo.
|
<permission
|
||||||
|
android:name="${applicationId}.permission.WRITE_TASKS"
|
||||||
Two more permissions appear in the merged manifest without being
|
android:description="@string/permission_write_tasks_description"
|
||||||
declared here, and both come from work-runtime: WAKE_LOCK, and
|
android:label="@string/permission_write_tasks_label"
|
||||||
FOREGROUND_SERVICE. The latter is not us taking the FGS route — below
|
android:protectionLevel="dangerous" />
|
||||||
API 31 WorkManager implements expedited work with a foreground service,
|
|
||||||
and minSdk is 29, so it is load-bearing for the "Sync now" button.
|
|
||||||
Removing it with tools:node="remove" would break expedited work on
|
|
||||||
exactly the older devices that need it most. Noted because it shows in
|
|
||||||
F-Droid's permission diff and would otherwise look unexplained. -->
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
|
||||||
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
|
|
||||||
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
|
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
@@ -55,23 +45,17 @@
|
|||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent>
|
</intent>
|
||||||
<!-- Custom Tabs provider detection. Without this entry it silently finds
|
|
||||||
nothing on API 30+, and the Nextcloud login flow falls back to an
|
|
||||||
external browser for no visible reason. -->
|
|
||||||
<intent>
|
|
||||||
<action android:name="android.support.customtabs.action.CustomTabsService" />
|
|
||||||
</intent>
|
|
||||||
</queries>
|
</queries>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".AgendulaApp"
|
android:name=".AgendulaApp"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
android:enableOnBackInvokedCallback="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:localeConfig="@xml/locales_config"
|
android:localeConfig="@xml/locales_config"
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.Agendula"
|
android:theme="@style/Theme.Agendula"
|
||||||
@@ -185,6 +169,58 @@
|
|||||||
android:resource="@xml/appwidget_info_tasks" />
|
android:resource="@xml/appwidget_info_tasks" />
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
|
<!-- "Today" home-screen widget (Glance): a progress ring for today's tasks. -->
|
||||||
|
<receiver
|
||||||
|
android:name=".widget.TodayWidgetReceiver"
|
||||||
|
android:label="@string/widget_today_label"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.appwidget.provider"
|
||||||
|
android:resource="@xml/appwidget_info_today" />
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<!-- "Up next" home-screen widget (Glance): the single nearest due task. -->
|
||||||
|
<receiver
|
||||||
|
android:name=".widget.UpNextWidgetReceiver"
|
||||||
|
android:label="@string/widget_up_next_label"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.appwidget.provider"
|
||||||
|
android:resource="@xml/appwidget_info_up_next" />
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<!-- "Lists" home-screen widget (Glance): one shortcut tile per list. -->
|
||||||
|
<receiver
|
||||||
|
android:name=".widget.ListsWidgetReceiver"
|
||||||
|
android:label="@string/widget_lists_label"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.appwidget.provider"
|
||||||
|
android:resource="@xml/appwidget_info_lists" />
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<!-- "Week" home-screen widget (Glance): a seven-day strip of task counts. -->
|
||||||
|
<receiver
|
||||||
|
android:name=".widget.WeekWidgetReceiver"
|
||||||
|
android:label="@string/widget_week_label"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.appwidget.provider"
|
||||||
|
android:resource="@xml/appwidget_info_week" />
|
||||||
|
</receiver>
|
||||||
|
|
||||||
<!-- Per-widget list picker, launched by the host on placement / reconfigure. -->
|
<!-- Per-widget list picker, launched by the host on placement / reconfigure. -->
|
||||||
<activity
|
<activity
|
||||||
android:name=".widget.TaskWidgetConfigActivity"
|
android:name=".widget.TaskWidgetConfigActivity"
|
||||||
@@ -211,53 +247,15 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
<!-- Sync plumbing. The stub provider exists only to give the sync
|
<!-- The own store under the OpenTasks contract. Empty, and refusing
|
||||||
adapter an authority to register against: Agendula publishes no real
|
writes, while External mode is active. -->
|
||||||
ContentProvider since :provider was deleted, and without an authority
|
|
||||||
ContentService.hasAuthorityAccess() makes every ContentResolver sync
|
|
||||||
call a silent no-op at targetSdk >= 34. -->
|
|
||||||
<provider
|
<provider
|
||||||
android:name=".data.sync.SyncStubProvider"
|
android:name=".data.tasks.provider.AgendulaTaskProvider"
|
||||||
android:authorities="${applicationId}.sync"
|
android:authorities="${applicationId}.tasks"
|
||||||
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:exported="true"
|
||||||
android:permission="android.permission.ACCOUNT_MANAGER">
|
android:readPermission="${applicationId}.permission.READ_TASKS"
|
||||||
<intent-filter>
|
android:writePermission="${applicationId}.permission.WRITE_TASKS"
|
||||||
<action android:name="android.accounts.AccountAuthenticator" />
|
tools:ignore="SystemPermissionTypo" />
|
||||||
</intent-filter>
|
|
||||||
<meta-data
|
|
||||||
android:name="android.accounts.AccountAuthenticator"
|
|
||||||
android:resource="@xml/authenticator" />
|
|
||||||
</service>
|
|
||||||
|
|
||||||
<service
|
|
||||||
android:name=".data.sync.SyncAdapterService"
|
|
||||||
android:exported="true"
|
|
||||||
android:permission="android.permission.BIND_SYNC_ADAPTER">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.content.SyncAdapter" />
|
|
||||||
</intent-filter>
|
|
||||||
<meta-data
|
|
||||||
android:name="android.content.SyncAdapter"
|
|
||||||
android:resource="@xml/sync_adapter" />
|
|
||||||
</service>
|
|
||||||
|
|
||||||
<!-- UnifiedPush: the connector binds this to deliver endpoints and
|
|
||||||
WebDAV-Push messages. Not exported; the connector's own receiver is
|
|
||||||
what distributors talk to. -->
|
|
||||||
<service
|
|
||||||
android:name=".data.sync.push.AgendulaPushService"
|
|
||||||
android:exported="false">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT" />
|
|
||||||
</intent-filter>
|
|
||||||
</service>
|
|
||||||
|
|
||||||
<!-- WorkManager's on-demand initialisation. Removing the default
|
<!-- WorkManager's on-demand initialisation. Removing the default
|
||||||
initializer is what lets AgendulaApp supply a HiltWorkerFactory, so
|
initializer is what lets AgendulaApp supply a HiltWorkerFactory, so
|
||||||
|
|||||||
@@ -10,12 +10,13 @@ import dagger.hilt.android.EntryPointAccessors
|
|||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
||||||
|
import de.jeanlucmakiola.agendula.data.di.ChannelRefresher
|
||||||
import de.jeanlucmakiola.agendula.data.reminders.ReminderMaintenanceWorker
|
import de.jeanlucmakiola.agendula.data.reminders.ReminderMaintenanceWorker
|
||||||
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
|
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
|
||||||
import de.jeanlucmakiola.agendula.data.reminders.TaskNotifier
|
import de.jeanlucmakiola.agendula.data.reminders.TaskNotifier
|
||||||
import de.jeanlucmakiola.agendula.data.sync.SyncNoticeNotifier
|
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.StartupGate
|
import de.jeanlucmakiola.agendula.data.tasks.StartupGate
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.provider.ProviderSnapshotSource
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.DatabaseCheckpoint
|
import de.jeanlucmakiola.agendula.data.tasks.room.DatabaseCheckpoint
|
||||||
import de.jeanlucmakiola.agendula.widget.TaskWidgetUpdater
|
import de.jeanlucmakiola.agendula.widget.TaskWidgetUpdater
|
||||||
import de.jeanlucmakiola.floret.crash.CrashConfig
|
import de.jeanlucmakiola.floret.crash.CrashConfig
|
||||||
@@ -87,6 +88,7 @@ class AgendulaApp : Application(), Configuration.Provider {
|
|||||||
scheduler.sync()
|
scheduler.sync()
|
||||||
}
|
}
|
||||||
runCatching { entryPoint.taskWidgetUpdater().start() }
|
runCatching { entryPoint.taskWidgetUpdater().start() }
|
||||||
|
runCatching { entryPoint.providerSnapshotSource().start() }
|
||||||
runCatching { ReminderMaintenanceWorker.schedule(this@AgendulaApp) }
|
runCatching { ReminderMaintenanceWorker.schedule(this@AgendulaApp) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,7 +107,7 @@ class AgendulaApp : Application(), Configuration.Provider {
|
|||||||
val entryPoint = EntryPointAccessors.fromApplication(this, AppEntryPoint::class.java)
|
val entryPoint = EntryPointAccessors.fromApplication(this, AppEntryPoint::class.java)
|
||||||
runCatching {
|
runCatching {
|
||||||
entryPoint.taskNotifier().refreshChannel()
|
entryPoint.taskNotifier().refreshChannel()
|
||||||
entryPoint.syncNoticeNotifier().refreshChannel()
|
entryPoint.channelRefreshers().forEach { it.refreshChannel() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +122,8 @@ class AgendulaApp : Application(), Configuration.Provider {
|
|||||||
fun applicationScope(): CoroutineScope
|
fun applicationScope(): CoroutineScope
|
||||||
fun databaseCheckpoint(): DatabaseCheckpoint
|
fun databaseCheckpoint(): DatabaseCheckpoint
|
||||||
fun taskNotifier(): TaskNotifier
|
fun taskNotifier(): TaskNotifier
|
||||||
fun syncNoticeNotifier(): SyncNoticeNotifier
|
fun channelRefreshers(): Set<@JvmSuppressWildcards ChannelRefresher>
|
||||||
fun taskWidgetUpdater(): TaskWidgetUpdater
|
fun taskWidgetUpdater(): TaskWidgetUpdater
|
||||||
|
fun providerSnapshotSource(): ProviderSnapshotSource
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import de.jeanlucmakiola.agendula.data.di.LaunchHook
|
import de.jeanlucmakiola.agendula.data.di.LaunchHook
|
||||||
import de.jeanlucmakiola.agendula.data.sync.SyncNoticeNotifier
|
|
||||||
import de.jeanlucmakiola.agendula.domain.SmartList
|
import de.jeanlucmakiola.agendula.domain.SmartList
|
||||||
import de.jeanlucmakiola.agendula.ui.imports.importIntentUri
|
import de.jeanlucmakiola.agendula.ui.imports.importIntentUri
|
||||||
import de.jeanlucmakiola.agendula.ui.navigation.AppShortcuts
|
import de.jeanlucmakiola.agendula.ui.navigation.AppShortcuts
|
||||||
@@ -33,10 +32,6 @@ import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
|||||||
import de.jeanlucmakiola.agendula.ui.RootScreen
|
import de.jeanlucmakiola.agendula.ui.RootScreen
|
||||||
import de.jeanlucmakiola.agendula.ui.crash.CrashReportActivity
|
import de.jeanlucmakiola.agendula.ui.crash.CrashReportActivity
|
||||||
import de.jeanlucmakiola.agendula.ui.navigation.NavRequest
|
import de.jeanlucmakiola.agendula.ui.navigation.NavRequest
|
||||||
import de.jeanlucmakiola.agendula.data.sync.AccountRepository
|
|
||||||
import de.jeanlucmakiola.agendula.data.sync.PendingLoginFlowStore
|
|
||||||
import de.jeanlucmakiola.agendula.data.sync.SyncTrigger
|
|
||||||
import de.jeanlucmakiola.agendula.data.sync.push.PushRegistrar
|
|
||||||
import de.jeanlucmakiola.agendula.ui.settings.SettingsViewModel
|
import de.jeanlucmakiola.agendula.ui.settings.SettingsViewModel
|
||||||
import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme
|
import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme
|
||||||
import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
||||||
@@ -55,14 +50,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
@Inject lateinit var launchHooks: Set<@JvmSuppressWildcards LaunchHook>
|
@Inject lateinit var launchHooks: Set<@JvmSuppressWildcards LaunchHook>
|
||||||
|
|
||||||
@Inject lateinit var accounts: AccountRepository
|
|
||||||
|
|
||||||
@Inject lateinit var syncTrigger: SyncTrigger
|
|
||||||
|
|
||||||
@Inject lateinit var pendingLoginFlows: PendingLoginFlowStore
|
|
||||||
|
|
||||||
@Inject lateinit var push: PushRegistrar
|
|
||||||
|
|
||||||
// A captured crash report awaiting the user's decision, surfaced as a dialog
|
// A captured crash report awaiting the user's decision, surfaced as a dialog
|
||||||
// over the app on the next launch (the single-crash path). A startup
|
// over the app on the next launch (the single-crash path). A startup
|
||||||
// crash-loop is handled out of band, before setContent — see below.
|
// crash-loop is handled out of band, before setContent — see below.
|
||||||
@@ -94,34 +81,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
// Surface a single captured crash as a dialog on the next launch.
|
// Surface a single captured crash as a dialog on the next launch.
|
||||||
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
||||||
|
|
||||||
// Sync hard on app open: the periodic worker's interval is a floor, and
|
// Each hook on its own, so one waiting on the network holds up no other.
|
||||||
// in the `rare` and `restricted` App Standby buckets it may not have run
|
|
||||||
// at all. `KEEP` makes rescheduling idempotent, so this also repairs a
|
|
||||||
// schedule lost to "clear app data" or to a restore.
|
|
||||||
//
|
|
||||||
// ⚠️ Only on a genuine open. This activity declares no `configChanges`,
|
|
||||||
// so onCreate runs again on every rotation, theme switch, locale change
|
|
||||||
// and font-scale change — each of which would otherwise start a fresh
|
|
||||||
// network sync the moment the previous one finished.
|
|
||||||
if (savedInstanceState == null) {
|
if (savedInstanceState == null) {
|
||||||
lifecycleScope.launch {
|
launchHooks.forEach { hook -> lifecycleScope.launch { runCatching { hook.onLaunch(intent) } } }
|
||||||
runCatching {
|
|
||||||
accounts.rescheduleAll()
|
|
||||||
accounts.syncable().forEach { syncTrigger.enqueue(it.displayName) }
|
|
||||||
// A login flow the previous process died in the middle of.
|
|
||||||
// Its password, if the user approved, exists nowhere else.
|
|
||||||
pendingLoginFlows.reclaim()
|
|
||||||
}
|
|
||||||
// Last and on its own: it waits on the network, and must not
|
|
||||||
// hold up the reclaim above. Re-registering on open is what
|
|
||||||
// the connector recommends.
|
|
||||||
runCatching { push.updateAll() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Variant hooks: the demo seeder in debug builds, nothing in release.
|
|
||||||
if (savedInstanceState == null) {
|
|
||||||
lifecycleScope.launch { launchHooks.forEach { runCatching { it.onLaunch(intent) } } }
|
|
||||||
}
|
}
|
||||||
setContent {
|
setContent {
|
||||||
val settingsViewModel: SettingsViewModel = hiltViewModel()
|
val settingsViewModel: SettingsViewModel = hiltViewModel()
|
||||||
@@ -180,12 +142,18 @@ class MainActivity : ComponentActivity() {
|
|||||||
const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID"
|
const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID"
|
||||||
const val EXTRA_OCCURRENCE_START = "de.jeanlucmakiola.agendula.extra.OCCURRENCE_START"
|
const val EXTRA_OCCURRENCE_START = "de.jeanlucmakiola.agendula.extra.OCCURRENCE_START"
|
||||||
private const val EXTRA_OPEN_ACCOUNTS = "de.jeanlucmakiola.agendula.extra.OPEN_ACCOUNTS"
|
private const val EXTRA_OPEN_ACCOUNTS = "de.jeanlucmakiola.agendula.extra.OPEN_ACCOUNTS"
|
||||||
|
|
||||||
|
/** The account a sign-in notification is about, on the intent it opens. */
|
||||||
|
const val EXTRA_SIGN_IN_ACCOUNT_ID = "de.jeanlucmakiola.agendula.extra.SIGN_IN_ACCOUNT_ID"
|
||||||
const val ACTION_NEW_TASK = "de.jeanlucmakiola.agendula.action.NEW_TASK"
|
const val ACTION_NEW_TASK = "de.jeanlucmakiola.agendula.action.NEW_TASK"
|
||||||
const val ACTION_TODAY = "de.jeanlucmakiola.agendula.action.TODAY"
|
const val ACTION_TODAY = "de.jeanlucmakiola.agendula.action.TODAY"
|
||||||
private const val ACTION_OPEN_SMART = "de.jeanlucmakiola.agendula.action.OPEN_SMART"
|
private const val ACTION_OPEN_SMART = "de.jeanlucmakiola.agendula.action.OPEN_SMART"
|
||||||
private const val ACTION_OPEN_LIST = "de.jeanlucmakiola.agendula.action.OPEN_LIST"
|
private const val ACTION_OPEN_LIST = "de.jeanlucmakiola.agendula.action.OPEN_LIST"
|
||||||
|
private const val ACTION_OPEN_FILTER = "de.jeanlucmakiola.agendula.action.OPEN_FILTER"
|
||||||
|
private const val EXTRA_FILTER_ID = "de.jeanlucmakiola.agendula.extra.FILTER_ID"
|
||||||
private const val EXTRA_SMART_LIST = "de.jeanlucmakiola.agendula.extra.SMART_LIST"
|
private const val EXTRA_SMART_LIST = "de.jeanlucmakiola.agendula.extra.SMART_LIST"
|
||||||
private const val EXTRA_LIST_ID = "de.jeanlucmakiola.agendula.extra.LIST_ID"
|
private const val EXTRA_LIST_ID = "de.jeanlucmakiola.agendula.extra.LIST_ID"
|
||||||
|
private const val EXTRA_PRESET_LIST_ID = "de.jeanlucmakiola.agendula.extra.PRESET_LIST_ID"
|
||||||
private const val SHARED_TITLE_LIMIT = 500
|
private const val SHARED_TITLE_LIMIT = 500
|
||||||
private const val NO_OCCURRENCE = -1L
|
private const val NO_OCCURRENCE = -1L
|
||||||
|
|
||||||
@@ -206,9 +174,14 @@ class MainActivity : ComponentActivity() {
|
|||||||
.putExtra(EXTRA_OPEN_ACCOUNTS, true)
|
.putExtra(EXTRA_OPEN_ACCOUNTS, true)
|
||||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
|
||||||
/** The launcher shortcut's and Quick Settings tile's "New task". */
|
/**
|
||||||
fun newTaskIntent(context: Context): Intent =
|
* The launcher shortcut's, Quick Settings tile's and a widget's "New
|
||||||
Intent(ACTION_NEW_TASK, null, context, MainActivity::class.java)
|
* task". [listId] preset the list when it names exactly one; the data
|
||||||
|
* URI keeps each widget's PendingIntent apart from the others.
|
||||||
|
*/
|
||||||
|
fun newTaskIntent(context: Context, listId: Long? = null): Intent =
|
||||||
|
Intent(ACTION_NEW_TASK, listId?.let { "agendula://newtask/$it".toUri() }, context, MainActivity::class.java)
|
||||||
|
.putExtra(EXTRA_PRESET_LIST_ID, listId ?: -1L)
|
||||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
|
||||||
fun todayIntent(context: Context): Intent =
|
fun todayIntent(context: Context): Intent =
|
||||||
@@ -227,19 +200,31 @@ class MainActivity : ComponentActivity() {
|
|||||||
.putExtra(EXTRA_LIST_ID, listId)
|
.putExtra(EXTRA_LIST_ID, listId)
|
||||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
|
||||||
|
/** Opens one saved filter. */
|
||||||
|
fun filterIntent(context: Context, filterId: Long): Intent =
|
||||||
|
Intent(ACTION_OPEN_FILTER, "agendula://filter/$filterId".toUri(), context, MainActivity::class.java)
|
||||||
|
.putExtra(EXTRA_FILTER_ID, filterId)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
|
||||||
internal fun navRequestOf(intent: Intent?): NavRequest? {
|
internal fun navRequestOf(intent: Intent?): NavRequest? {
|
||||||
if (intent == null) return null
|
if (intent == null) return null
|
||||||
intent.getLongExtra(SyncNoticeNotifier.EXTRA_SIGN_IN_ACCOUNT_ID, -1L).takeIf { it > 0L }
|
if (BuildConfig.SYNC_ENABLED) {
|
||||||
?.let { return NavRequest.OpenAccount(it) }
|
intent.getLongExtra(EXTRA_SIGN_IN_ACCOUNT_ID, -1L).takeIf { it > 0L }
|
||||||
if (intent.getBooleanExtra(EXTRA_OPEN_ACCOUNTS, false)) return NavRequest.OpenAccounts
|
?.let { return NavRequest.OpenAccount(it) }
|
||||||
|
if (intent.getBooleanExtra(EXTRA_OPEN_ACCOUNTS, false)) return NavRequest.OpenAccounts
|
||||||
|
}
|
||||||
when (intent.action) {
|
when (intent.action) {
|
||||||
ACTION_NEW_TASK -> return NavRequest.NewTask()
|
ACTION_NEW_TASK -> return NavRequest.NewTask(
|
||||||
|
listId = intent.getLongExtra(EXTRA_PRESET_LIST_ID, -1L).takeIf { it > 0L },
|
||||||
|
)
|
||||||
ACTION_TODAY -> return NavRequest.OpenSmart(SmartList.TODAY)
|
ACTION_TODAY -> return NavRequest.OpenSmart(SmartList.TODAY)
|
||||||
ACTION_OPEN_SMART -> intent.getStringExtra(EXTRA_SMART_LIST)
|
ACTION_OPEN_SMART -> intent.getStringExtra(EXTRA_SMART_LIST)
|
||||||
?.let { name -> SmartList.entries.firstOrNull { it.name == name } }
|
?.let { name -> SmartList.entries.firstOrNull { it.name == name } }
|
||||||
?.let { return NavRequest.OpenSmart(it) }
|
?.let { return NavRequest.OpenSmart(it) }
|
||||||
ACTION_OPEN_LIST -> intent.getLongExtra(EXTRA_LIST_ID, -1L).takeIf { it > 0L }
|
ACTION_OPEN_LIST -> intent.getLongExtra(EXTRA_LIST_ID, -1L).takeIf { it > 0L }
|
||||||
?.let { return NavRequest.OpenList(it) }
|
?.let { return NavRequest.OpenList(it) }
|
||||||
|
ACTION_OPEN_FILTER -> intent.getLongExtra(EXTRA_FILTER_ID, -1L).takeIf { it > 0L }
|
||||||
|
?.let { return NavRequest.OpenFilter(it) }
|
||||||
}
|
}
|
||||||
importIntentUri(intent)?.let { return NavRequest.Import(it) }
|
importIntentUri(intent)?.let { return NavRequest.Import(it) }
|
||||||
if (intent.action == Intent.ACTION_SEND && intent.type?.startsWith("text/plain") == true) {
|
if (intent.action == Intent.ACTION_SEND && intent.type?.startsWith("text/plain") == true) {
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.di
|
||||||
|
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import dagger.multibindings.Multibinds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A notification channel a build variant adds, re-created in the current
|
||||||
|
* language on start and on a configuration change. The `full` flavor
|
||||||
|
* contributes the sync notices; `offline` has none.
|
||||||
|
*/
|
||||||
|
fun interface ChannelRefresher {
|
||||||
|
fun refreshChannel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
abstract class ChannelRefresherModule {
|
||||||
|
@Multibinds
|
||||||
|
abstract fun channelRefreshers(): Set<ChannelRefresher>
|
||||||
|
}
|
||||||
@@ -12,13 +12,6 @@ import dagger.Provides
|
|||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import de.jeanlucmakiola.agendula.data.sync.AccountCreator
|
|
||||||
import de.jeanlucmakiola.agendula.data.sync.AccountRepository
|
|
||||||
import de.jeanlucmakiola.agendula.data.sync.CalDavGateway
|
|
||||||
import de.jeanlucmakiola.agendula.data.sync.LoginFlowRecord
|
|
||||||
import de.jeanlucmakiola.agendula.data.sync.PendingLoginFlowStore
|
|
||||||
import de.jeanlucmakiola.agendula.data.sync.SyncOnEdit
|
|
||||||
import de.jeanlucmakiola.agendula.data.sync.OkHttpCalDavGateway
|
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.AndroidProviderEnvironment
|
import de.jeanlucmakiola.agendula.data.tasks.AndroidProviderEnvironment
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.AndroidTasksDataSource
|
import de.jeanlucmakiola.agendula.data.tasks.AndroidTasksDataSource
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.ModeRoutingTasksDataSource
|
import de.jeanlucmakiola.agendula.data.tasks.ModeRoutingTasksDataSource
|
||||||
@@ -27,7 +20,6 @@ import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
|||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepositoryImpl
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepositoryImpl
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.LocalWriteListener
|
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
|
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||||
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
||||||
@@ -51,37 +43,13 @@ import javax.inject.Singleton
|
|||||||
* reconciled", and the credential store to accounts that ask to be signed in
|
* reconciled", and the credential store to accounts that ask to be signed in
|
||||||
* again — all states the app already knows how to be in, unlike a launch loop.
|
* again — all states the app already knows how to be in, unlike a launch loop.
|
||||||
*/
|
*/
|
||||||
private fun replaceCorrupted() = ReplaceFileCorruptionHandler { emptyPreferences() }
|
internal fun replaceCorrupted() = ReplaceFileCorruptionHandler { emptyPreferences() }
|
||||||
|
|
||||||
private val Context.agendulaDataStore: DataStore<Preferences> by preferencesDataStore(
|
private val Context.agendulaDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||||
name = "agendula_prefs",
|
name = "agendula_prefs",
|
||||||
corruptionHandler = replaceCorrupted(),
|
corruptionHandler = replaceCorrupted(),
|
||||||
)
|
)
|
||||||
|
|
||||||
/** See [CredentialsDataStore] for why this is a separate file. */
|
|
||||||
private val Context.credentialsDataStore: DataStore<Preferences> by preferencesDataStore(
|
|
||||||
name = CREDENTIALS_DATASTORE,
|
|
||||||
corruptionHandler = replaceCorrupted(),
|
|
||||||
)
|
|
||||||
|
|
||||||
/** See [SyncStateDataStore] for why this is a separate file. */
|
|
||||||
private val Context.syncStateDataStore: DataStore<Preferences> by preferencesDataStore(
|
|
||||||
name = SYNC_STATE_DATASTORE,
|
|
||||||
corruptionHandler = replaceCorrupted(),
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Named here and in `backup_rules.xml` / `data_extraction_rules.xml`, which
|
|
||||||
* exclude `datastore/$CREDENTIALS_DATASTORE.preferences_pb` by this name.
|
|
||||||
*/
|
|
||||||
const val CREDENTIALS_DATASTORE = "agendula_credentials"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Named here and in `backup_rules.xml` / `data_extraction_rules.xml`, which
|
|
||||||
* exclude `datastore/$SYNC_STATE_DATASTORE.preferences_pb` by this name.
|
|
||||||
*/
|
|
||||||
const val SYNC_STATE_DATASTORE = "agendula_sync_state"
|
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
abstract class DataBindModule {
|
abstract class DataBindModule {
|
||||||
@@ -94,22 +62,6 @@ abstract class DataBindModule {
|
|||||||
@Singleton
|
@Singleton
|
||||||
abstract fun bindProviderEnvironment(impl: AndroidProviderEnvironment): ProviderEnvironment
|
abstract fun bindProviderEnvironment(impl: AndroidProviderEnvironment): ProviderEnvironment
|
||||||
|
|
||||||
@Binds
|
|
||||||
@Singleton
|
|
||||||
abstract fun bindCalDavGateway(impl: OkHttpCalDavGateway): CalDavGateway
|
|
||||||
|
|
||||||
@Binds
|
|
||||||
@Singleton
|
|
||||||
abstract fun bindAccountCreator(impl: AccountRepository): AccountCreator
|
|
||||||
|
|
||||||
@Binds
|
|
||||||
@Singleton
|
|
||||||
abstract fun bindLoginFlowRecord(impl: PendingLoginFlowStore): LoginFlowRecord
|
|
||||||
|
|
||||||
@Binds
|
|
||||||
@Singleton
|
|
||||||
abstract fun bindLocalWriteListener(impl: SyncOnEdit): LocalWriteListener
|
|
||||||
|
|
||||||
// Deliberately unqualified-free of the routing above: this is the external
|
// Deliberately unqualified-free of the routing above: this is the external
|
||||||
// store itself, for the one caller that has to read it while another store is
|
// store itself, for the one caller that has to read it while another store is
|
||||||
// the active one.
|
// the active one.
|
||||||
@@ -128,18 +80,6 @@ object DataProvideModule {
|
|||||||
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
||||||
context.agendulaDataStore
|
context.agendulaDataStore
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
@CredentialsDataStore
|
|
||||||
fun provideCredentialsDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
|
||||||
context.credentialsDataStore
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
@SyncStateDataStore
|
|
||||||
fun provideSyncStateDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
|
||||||
context.syncStateDataStore
|
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideTasksDatabase(@ApplicationContext context: Context): TasksDatabase =
|
fun provideTasksDatabase(@ApplicationContext context: Context): TasksDatabase =
|
||||||
@@ -149,6 +89,7 @@ object DataProvideModule {
|
|||||||
// backed-up `.db` does not. The backup rules carry all three files and
|
// backed-up `.db` does not. The backup rules carry all three files and
|
||||||
// the app checkpoints on ON_STOP.
|
// the app checkpoints on ON_STOP.
|
||||||
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
|
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
|
||||||
|
.addMigrations(TasksDatabase.MIGRATION_1_2, TasksDatabase.MIGRATION_2_3)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,9 +7,8 @@ import dagger.hilt.components.SingletonComponent
|
|||||||
import dagger.multibindings.Multibinds
|
import dagger.multibindings.Multibinds
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Something a build variant wants to run when the app is launched with [Intent].
|
* Something a build variant runs on a genuine app open (not on a configuration
|
||||||
* Release builds contribute none; the debug source set adds the demo seeder, so
|
* change): sync in the `full` flavor, the demo seeder in debug builds.
|
||||||
* that class never ships.
|
|
||||||
*/
|
*/
|
||||||
fun interface LaunchHook {
|
fun interface LaunchHook {
|
||||||
suspend fun onLaunch(intent: Intent)
|
suspend fun onLaunch(intent: Intent)
|
||||||
|
|||||||
@@ -17,37 +17,6 @@ annotation class IoDispatcher
|
|||||||
@Retention(AnnotationRetention.BINARY)
|
@Retention(AnnotationRetention.BINARY)
|
||||||
annotation class ApplicationScope
|
annotation class ApplicationScope
|
||||||
|
|
||||||
/**
|
|
||||||
* Marks the DataStore holding **only** the Keystore-encrypted app passwords.
|
|
||||||
*
|
|
||||||
* A separate file from `agendula_prefs` on purpose: Auto Backup includes
|
|
||||||
* `datastore/`, and a restored ciphertext is permanently undecryptable because
|
|
||||||
* Keystore keys are non-exportable. Its own file is what lets the backup rules
|
|
||||||
* exclude the credentials and nothing else — excluding the whole database or
|
|
||||||
* all of DataStore would trade a latent bug for a live one.
|
|
||||||
*/
|
|
||||||
@Qualifier
|
|
||||||
@Retention(AnnotationRetention.BINARY)
|
|
||||||
annotation class CredentialsDataStore
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Marks the DataStore holding per-device **sync bookkeeping** — the quarantine
|
|
||||||
* counters and the full-reconciliation clock.
|
|
||||||
*
|
|
||||||
* Its own file for the same reason the credentials have one: Auto Backup
|
|
||||||
* includes `datastore/`, and every value in here is a statement about *this*
|
|
||||||
* device's conversation with a server. Restored onto a new install they are all
|
|
||||||
* lies, and two of them are dangerous — a restored "reconciled recently" makes
|
|
||||||
* the engine trust a sync token for another day, which is precisely the silently
|
|
||||||
* pruned change log the full path exists to catch, and a restored quarantine
|
|
||||||
* count silently skips resources that were never tried here.
|
|
||||||
*
|
|
||||||
* Not user data, so nothing is lost by excluding it.
|
|
||||||
*/
|
|
||||||
@Qualifier
|
|
||||||
@Retention(AnnotationRetention.BINARY)
|
|
||||||
annotation class SyncStateDataStore
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Marks the **external** provider's [de.jeanlucmakiola.agendula.data.tasks
|
* Marks the **external** provider's [de.jeanlucmakiola.agendula.data.tasks
|
||||||
* .TasksDataSource] — the OpenTasks/tasks.org path specifically, rather than
|
* .TasksDataSource] — the OpenTasks/tasks.org path specifically, rather than
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.prefs
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.agendula.domain.DueWindow
|
||||||
|
import de.jeanlucmakiola.agendula.domain.Priority
|
||||||
|
import de.jeanlucmakiola.agendula.domain.SavedFilter
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TagMatch
|
||||||
|
import java.net.URLDecoder
|
||||||
|
import java.net.URLEncoder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stored form of the saved filters: one line per filter,
|
||||||
|
* `id|name|due|lists|tags|match|priority`, with name and tags URL-encoded and
|
||||||
|
* list ids qualified by the store they belong to (`OWN:3`), since each store
|
||||||
|
* numbers its lists on its own. Fixed once released — don't change without a
|
||||||
|
* data migration.
|
||||||
|
*/
|
||||||
|
internal object SavedFilterCodec {
|
||||||
|
|
||||||
|
/** The filters in [raw], with only the list ids of [mode]'s store. */
|
||||||
|
fun decode(raw: String?, mode: String): List<SavedFilter> =
|
||||||
|
lines(raw).mapNotNull { runCatching { decodeLine(it, mode) }.getOrNull() }
|
||||||
|
|
||||||
|
/** [raw] with [filter] replacing the line of the same id, or appended. */
|
||||||
|
fun upsert(raw: String?, filter: SavedFilter, mode: String): String {
|
||||||
|
val existing = lines(raw)
|
||||||
|
val index = existing.indexOfFirst { idOf(it) == filter.id }
|
||||||
|
// Keep the lists another store contributed, so switching stores and back
|
||||||
|
// doesn't widen a filter that was edited in between, unless "any list"
|
||||||
|
// was chosen here, which drops them on purpose.
|
||||||
|
val foreign = if (filter.anyList) {
|
||||||
|
emptyList()
|
||||||
|
} else {
|
||||||
|
existing.getOrNull(index)?.let(::fields)?.getOrNull(3)
|
||||||
|
?.let(::splitList)?.filterNot { it.startsWith("$mode:") }.orEmpty()
|
||||||
|
}
|
||||||
|
val line = encodeLine(filter, mode, foreign)
|
||||||
|
val updated = if (index >= 0) existing.toMutableList().also { it[index] = line } else existing + line
|
||||||
|
return updated.joinToString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun remove(raw: String?, id: Long): String = lines(raw).filterNot { idOf(it) == id }.joinToString("\n")
|
||||||
|
|
||||||
|
private fun lines(raw: String?): List<String> = raw.orEmpty().split('\n').filter { it.isNotBlank() }
|
||||||
|
|
||||||
|
private fun fields(line: String): List<String> = line.split('|')
|
||||||
|
|
||||||
|
private fun idOf(line: String): Long? = fields(line).firstOrNull()?.toLongOrNull()
|
||||||
|
|
||||||
|
private fun splitList(value: String): List<String> = value.split(',').filter { it.isNotEmpty() }
|
||||||
|
|
||||||
|
private fun encodeLine(filter: SavedFilter, mode: String, foreignLists: List<String>): String =
|
||||||
|
listOf(
|
||||||
|
filter.id.toString(),
|
||||||
|
encode(filter.name),
|
||||||
|
filter.due.sorted().joinToString(",") { it.name },
|
||||||
|
(filter.listIds.sorted().map { "$mode:$it" } + foreignLists).joinToString(","),
|
||||||
|
filter.tags.sorted().joinToString(",") { encode(it) },
|
||||||
|
filter.tagMatch.name,
|
||||||
|
filter.minPriority.name,
|
||||||
|
).joinToString("|")
|
||||||
|
|
||||||
|
private fun decodeLine(line: String, mode: String): SavedFilter? {
|
||||||
|
val f = fields(line)
|
||||||
|
if (f.size < 7) return null
|
||||||
|
val id = f[0].toLongOrNull() ?: return null
|
||||||
|
val lists = splitList(f[3])
|
||||||
|
val here = lists.filter { it.startsWith("$mode:") }.mapNotNull { it.removePrefix("$mode:").toLongOrNull() }.toSet()
|
||||||
|
return SavedFilter(
|
||||||
|
id = id,
|
||||||
|
name = decode(f[1]),
|
||||||
|
due = splitList(f[2]).mapNotNull { runCatching { DueWindow.valueOf(it) }.getOrNull() }.toSet(),
|
||||||
|
listIds = here,
|
||||||
|
listsElsewhere = lists.any { !it.startsWith("$mode:") },
|
||||||
|
tags = splitList(f[4]).map(::decode).toSet(),
|
||||||
|
tagMatch = runCatching { TagMatch.valueOf(f[5]) }.getOrDefault(TagMatch.ANY),
|
||||||
|
minPriority = runCatching { Priority.valueOf(f[6]) }.getOrDefault(Priority.NONE),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun encode(value: String): String = URLEncoder.encode(value, Charsets.UTF_8.name())
|
||||||
|
|
||||||
|
private fun decode(value: String): String = URLDecoder.decode(value, Charsets.UTF_8.name())
|
||||||
|
}
|
||||||
@@ -10,16 +10,22 @@ import androidx.datastore.preferences.core.stringPreferencesKey
|
|||||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
|
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
|
||||||
|
import de.jeanlucmakiola.agendula.domain.SavedFilter
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||||
|
import de.jeanlucmakiola.agendula.domain.asTaskReminder
|
||||||
|
import de.jeanlucmakiola.agendula.domain.asReminderLead
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskSortOrder
|
import de.jeanlucmakiola.agendula.domain.TaskSortOrder
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderLead
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec
|
import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec
|
||||||
import de.jeanlucmakiola.floret.reminders.applyReminderOverride
|
import de.jeanlucmakiola.floret.reminders.applyReminderOverride
|
||||||
import de.jeanlucmakiola.floret.reminders.normalizeReminders
|
import de.jeanlucmakiola.floret.reminders.normalizeReminders
|
||||||
import de.jeanlucmakiola.floret.reminders.reminderLeadsFor
|
import de.jeanlucmakiola.floret.reminders.reminderLeadsFor
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import java.time.DayOfWeek
|
import java.time.DayOfWeek
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -55,10 +61,10 @@ data class Settings(
|
|||||||
val dynamicColor: Boolean = true,
|
val dynamicColor: Boolean = true,
|
||||||
/** The list a new task defaults to; `null` = first available. */
|
/** The list a new task defaults to; `null` = first available. */
|
||||||
val defaultListId: Long? = null,
|
val defaultListId: Long? = null,
|
||||||
/** Default reminders, as minutes before due (0 = at due time); empty = none. */
|
/** Default reminders (0 before due = at due time); empty = none. */
|
||||||
val defaultReminderMinutes: List<Int> = listOf(0),
|
val defaultReminders: List<ReminderLead> = listOf(ReminderLead(0)),
|
||||||
/** Default reminders for all-day tasks, as days-scale minutes before [allDayReminderMinuteOfDay]. */
|
/** Default reminders for all-day tasks, in whole days, rung at [allDayReminderMinuteOfDay]. */
|
||||||
val defaultAllDayReminderMinutes: List<Int> = listOf(0),
|
val defaultAllDayReminders: List<ReminderLead> = listOf(ReminderLead(0)),
|
||||||
/** Local time (minutes from midnight) an all-day task's reminder counts back from. */
|
/** Local time (minutes from midnight) an all-day task's reminder counts back from. */
|
||||||
val allDayReminderMinuteOfDay: Int = DEFAULT_ALL_DAY_REMINDER_MINUTE,
|
val allDayReminderMinuteOfDay: Int = DEFAULT_ALL_DAY_REMINDER_MINUTE,
|
||||||
/** Master switch for due reminders; off clears every scheduled alarm. */
|
/** Master switch for due reminders; off clears every scheduled alarm. */
|
||||||
@@ -72,13 +78,13 @@ data class Settings(
|
|||||||
*/
|
*/
|
||||||
val bottomAddBar: Boolean = false,
|
val bottomAddBar: Boolean = false,
|
||||||
/**
|
/**
|
||||||
* Per-list overrides of [defaultReminderMinutes]: a list present in the map
|
* Per-list overrides of [defaultReminders]: a list present in the map
|
||||||
* overrides the global default (an empty list = no reminder); absent =
|
* overrides the global default (an empty list = no reminder); absent =
|
||||||
* inherit.
|
* inherit.
|
||||||
*/
|
*/
|
||||||
val perListReminderOverride: Map<Long, List<Int>> = emptyMap(),
|
val perListReminderOverride: Map<Long, List<ReminderLead>> = emptyMap(),
|
||||||
/** Per-list overrides of [defaultAllDayReminderMinutes], same shape as [perListReminderOverride]. */
|
/** Per-list overrides of [defaultAllDayReminders], same shape as [perListReminderOverride]. */
|
||||||
val perListAllDayReminderOverride: Map<Long, List<Int>> = emptyMap(),
|
val perListAllDayReminderOverride: Map<Long, List<ReminderLead>> = emptyMap(),
|
||||||
/** Optional edit-form fields shown by default; the rest sit behind "More fields". */
|
/** Optional edit-form fields shown by default; the rest sit behind "More fields". */
|
||||||
val defaultEditFields: Set<TaskFormField> = emptySet(),
|
val defaultEditFields: Set<TaskFormField> = emptySet(),
|
||||||
val sortOrder: TaskSortOrder = TaskSortOrder.DUE,
|
val sortOrder: TaskSortOrder = TaskSortOrder.DUE,
|
||||||
@@ -95,6 +101,8 @@ data class Settings(
|
|||||||
val pushEnabled: Boolean = true,
|
val pushEnabled: Boolean = true,
|
||||||
/** Lists of the current store whose tasks the smart lists (and their counts) leave out. */
|
/** Lists of the current store whose tasks the smart lists (and their counts) leave out. */
|
||||||
val hiddenFromSmartLists: Set<Long> = emptySet(),
|
val hiddenFromSmartLists: Set<Long> = emptySet(),
|
||||||
|
/** Pre-fill a new task's start with today's date. */
|
||||||
|
val defaultStartToday: Boolean = false,
|
||||||
) {
|
) {
|
||||||
/** [tasks] as [filter] shows them: a smart list drops the lists kept out of it. */
|
/** [tasks] as [filter] shows them: a smart list drops the lists kept out of it. */
|
||||||
fun visibleIn(filter: TaskFilter, tasks: List<Task>): List<Task> =
|
fun visibleIn(filter: TaskFilter, tasks: List<Task>): List<Task> =
|
||||||
@@ -105,12 +113,16 @@ data class Settings(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The lead times for a task in [listId]: its override if set, else the global default. */
|
/** The lead times for a task in [listId]: its override if set, else the global default. */
|
||||||
fun reminderLeadsFor(listId: Long): List<Int> =
|
fun reminderLeadsFor(listId: Long): List<ReminderLead> =
|
||||||
perListReminderOverride.reminderLeadsFor(listId, defaultReminderMinutes)
|
perListReminderOverride.reminderLeadsFor(listId, defaultReminders)
|
||||||
|
|
||||||
/** The lead times for an all-day task in [listId]. */
|
/** The lead times for an all-day task in [listId]. */
|
||||||
fun allDayReminderLeadsFor(listId: Long): List<Int> =
|
fun allDayReminderLeadsFor(listId: Long): List<ReminderLead> =
|
||||||
perListAllDayReminderOverride.reminderLeadsFor(listId, defaultAllDayReminderMinutes)
|
perListAllDayReminderOverride.reminderLeadsFor(listId, defaultAllDayReminders)
|
||||||
|
|
||||||
|
/** The default reminders a task in [listId] takes — the one rule for which default applies. */
|
||||||
|
fun defaultRemindersFor(listId: Long, allDay: Boolean): List<TaskReminder> =
|
||||||
|
(if (allDay) allDayReminderLeadsFor(listId) else reminderLeadsFor(listId)).map { it.asTaskReminder() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */
|
/** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */
|
||||||
@@ -126,16 +138,18 @@ class SettingsPrefs @Inject constructor(
|
|||||||
dynamicColor = p[DYNAMIC_COLOR] ?: true,
|
dynamicColor = p[DYNAMIC_COLOR] ?: true,
|
||||||
defaultListId = p[DEFAULT_LIST_ID]?.takeIf { it > 0 },
|
defaultListId = p[DEFAULT_LIST_ID]?.takeIf { it > 0 },
|
||||||
// The single lead of earlier versions carries over until a list is saved.
|
// The single lead of earlier versions carries over until a list is saved.
|
||||||
defaultReminderMinutes = p[DEFAULT_REMINDERS]?.let(::parseMinutes)
|
defaultReminders = p[DEFAULT_REMINDERS]?.let(::parseLeads)
|
||||||
?: listOf(p[REMINDER_LEAD] ?: 0),
|
?: listOf(ReminderLead(p[REMINDER_LEAD] ?: 0)),
|
||||||
defaultAllDayReminderMinutes = p[DEFAULT_ALL_DAY_REMINDERS]?.let(::parseMinutes) ?: listOf(0),
|
defaultAllDayReminders = p[DEFAULT_ALL_DAY_REMINDERS]?.let(::parseLeads)?.inWholeDays()
|
||||||
|
?: listOf(ReminderLead(0)),
|
||||||
allDayReminderMinuteOfDay = p[ALL_DAY_REMINDER_MINUTE]?.takeIf { it in 0 until 24 * 60 }
|
allDayReminderMinuteOfDay = p[ALL_DAY_REMINDER_MINUTE]?.takeIf { it in 0 until 24 * 60 }
|
||||||
?: DEFAULT_ALL_DAY_REMINDER_MINUTE,
|
?: DEFAULT_ALL_DAY_REMINDER_MINUTE,
|
||||||
remindersEnabled = p[REMINDERS_ENABLED] ?: true,
|
remindersEnabled = p[REMINDERS_ENABLED] ?: true,
|
||||||
showAddSubtaskRow = p[SHOW_ADD_SUBTASK_ROW] ?: true,
|
showAddSubtaskRow = p[SHOW_ADD_SUBTASK_ROW] ?: true,
|
||||||
bottomAddBar = p[BOTTOM_ADD_BAR] ?: false,
|
bottomAddBar = p[BOTTOM_ADD_BAR] ?: false,
|
||||||
perListReminderOverride = reminderCodec.parse(p[LIST_REMINDER_OVERRIDE]),
|
perListReminderOverride = reminderCodec.parseLeads(p[LIST_REMINDER_OVERRIDE]),
|
||||||
perListAllDayReminderOverride = reminderCodec.parse(p[LIST_ALL_DAY_REMINDER_OVERRIDE]),
|
perListAllDayReminderOverride = reminderCodec.parseLeads(p[LIST_ALL_DAY_REMINDER_OVERRIDE])
|
||||||
|
.mapValues { it.value.inWholeDays() },
|
||||||
defaultEditFields = p[DEFAULT_EDIT_FIELDS].orEmpty()
|
defaultEditFields = p[DEFAULT_EDIT_FIELDS].orEmpty()
|
||||||
.mapNotNull { name -> runCatching { TaskFormField.valueOf(name) }.getOrNull() }
|
.mapNotNull { name -> runCatching { TaskFormField.valueOf(name) }.getOrNull() }
|
||||||
.toSet(),
|
.toSet(),
|
||||||
@@ -153,6 +167,7 @@ class SettingsPrefs @Inject constructor(
|
|||||||
.mapNotNull { entry -> entry.substringAfter("$mode:", "").toLongOrNull() }
|
.mapNotNull { entry -> entry.substringAfter("$mode:", "").toLongOrNull() }
|
||||||
.toSet()
|
.toSet()
|
||||||
},
|
},
|
||||||
|
defaultStartToday = p[DEFAULT_START_TODAY] ?: false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,6 +181,8 @@ class SettingsPrefs @Inject constructor(
|
|||||||
|
|
||||||
suspend fun setAutofocusTitle(enabled: Boolean) = dataStore.edit { it[AUTOFOCUS_TITLE] = enabled }
|
suspend fun setAutofocusTitle(enabled: Boolean) = dataStore.edit { it[AUTOFOCUS_TITLE] = enabled }
|
||||||
|
|
||||||
|
suspend fun setDefaultStartToday(enabled: Boolean) = dataStore.edit { it[DEFAULT_START_TODAY] = enabled }
|
||||||
|
|
||||||
suspend fun setTimeFormat(pref: TimeFormatPref) = dataStore.edit { it[TIME_FORMAT] = pref.name }
|
suspend fun setTimeFormat(pref: TimeFormatPref) = dataStore.edit { it[TIME_FORMAT] = pref.name }
|
||||||
|
|
||||||
suspend fun setWeekStart(day: DayOfWeek?) = dataStore.edit {
|
suspend fun setWeekStart(day: DayOfWeek?) = dataStore.edit {
|
||||||
@@ -178,12 +195,12 @@ class SettingsPrefs @Inject constructor(
|
|||||||
if (id == null) it.remove(DEFAULT_LIST_ID) else it[DEFAULT_LIST_ID] = id
|
if (id == null) it.remove(DEFAULT_LIST_ID) else it[DEFAULT_LIST_ID] = id
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setDefaultReminderMinutes(minutes: List<Int>) = dataStore.edit {
|
suspend fun setDefaultReminders(leads: List<ReminderLead>) = dataStore.edit {
|
||||||
it[DEFAULT_REMINDERS] = minutes.normalizeReminders().joinToString(",")
|
it[DEFAULT_REMINDERS] = leads.normalizeReminders().joinToString(",") { lead -> lead.toToken() }
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setDefaultAllDayReminderMinutes(minutes: List<Int>) = dataStore.edit {
|
suspend fun setDefaultAllDayReminders(leads: List<ReminderLead>) = dataStore.edit {
|
||||||
it[DEFAULT_ALL_DAY_REMINDERS] = minutes.normalizeReminders().joinToString(",")
|
it[DEFAULT_ALL_DAY_REMINDERS] = leads.normalizeReminders().joinToString(",") { lead -> lead.toToken() }
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setAllDayReminderMinuteOfDay(minuteOfDay: Int) =
|
suspend fun setAllDayReminderMinuteOfDay(minuteOfDay: Int) =
|
||||||
@@ -236,16 +253,16 @@ class SettingsPrefs @Inject constructor(
|
|||||||
|
|
||||||
/** Set (or clear, via [ReminderOverride.Inherit]) a list's reminder override. */
|
/** Set (or clear, via [ReminderOverride.Inherit]) a list's reminder override. */
|
||||||
suspend fun setListReminderOverride(listId: Long, override: ReminderOverride) = dataStore.edit { p ->
|
suspend fun setListReminderOverride(listId: Long, override: ReminderOverride) = dataStore.edit { p ->
|
||||||
val current = reminderCodec.parse(p[LIST_REMINDER_OVERRIDE]).toMutableMap()
|
val current = reminderCodec.parseLeads(p[LIST_REMINDER_OVERRIDE]).toMutableMap()
|
||||||
current.applyReminderOverride(listId, override)
|
current.applyReminderOverride(listId, override)
|
||||||
p[LIST_REMINDER_OVERRIDE] = reminderCodec.serialize(current)
|
p[LIST_REMINDER_OVERRIDE] = reminderCodec.serializeLeads(current)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Set (or clear) a list's all-day reminder override. */
|
/** Set (or clear) a list's all-day reminder override. */
|
||||||
suspend fun setListAllDayReminderOverride(listId: Long, override: ReminderOverride) = dataStore.edit { p ->
|
suspend fun setListAllDayReminderOverride(listId: Long, override: ReminderOverride) = dataStore.edit { p ->
|
||||||
val current = reminderCodec.parse(p[LIST_ALL_DAY_REMINDER_OVERRIDE]).toMutableMap()
|
val current = reminderCodec.parseLeads(p[LIST_ALL_DAY_REMINDER_OVERRIDE]).toMutableMap()
|
||||||
current.applyReminderOverride(listId, override)
|
current.applyReminderOverride(listId, override)
|
||||||
p[LIST_ALL_DAY_REMINDER_OVERRIDE] = reminderCodec.serialize(current)
|
p[LIST_ALL_DAY_REMINDER_OVERRIDE] = reminderCodec.serializeLeads(current)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setHiddenFromSmartLists(listId: Long, hidden: Boolean) = dataStore.edit { p ->
|
suspend fun setHiddenFromSmartLists(listId: Long, hidden: Boolean) = dataStore.edit { p ->
|
||||||
@@ -254,6 +271,34 @@ class SettingsPrefs @Inject constructor(
|
|||||||
p[SMART_LIST_HIDDEN] = if (hidden) current + entry else current - entry
|
p[SMART_LIST_HIDDEN] = if (hidden) current + entry else current - entry
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The saved filters, oldest first, with the list ids of the current store. */
|
||||||
|
val savedFilters: Flow<List<SavedFilter>> =
|
||||||
|
dataStore.data.map { p -> p[SAVED_FILTERS] to p[STORAGE_MODE] }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.map { (raw, mode) -> SavedFilterCodec.decode(raw, (storedMode(mode) ?: resolver.autoMode()).name) }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
|
||||||
|
/** One saved filter; `null` while it doesn't exist. */
|
||||||
|
fun savedFilter(id: Long): Flow<SavedFilter?> =
|
||||||
|
savedFilters.map { all -> all.firstOrNull { it.id == id } }.distinctUntilChanged()
|
||||||
|
|
||||||
|
/** Store [filter]; an id of 0 makes a new one. Returns the filter's id. */
|
||||||
|
suspend fun saveFilter(filter: SavedFilter): Long {
|
||||||
|
var id = filter.id
|
||||||
|
dataStore.edit { p ->
|
||||||
|
if (id <= 0) {
|
||||||
|
id = p[SAVED_FILTER_NEXT_ID] ?: 1L
|
||||||
|
p[SAVED_FILTER_NEXT_ID] = id + 1
|
||||||
|
}
|
||||||
|
p[SAVED_FILTERS] = SavedFilterCodec.upsert(p[SAVED_FILTERS], filter.copy(id = id), modeOf(p).name)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteFilter(id: Long) = dataStore.edit { p ->
|
||||||
|
p[SAVED_FILTERS] = SavedFilterCodec.remove(p[SAVED_FILTERS], id)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun setDefaultEditFields(fields: Set<TaskFormField>) = dataStore.edit {
|
suspend fun setDefaultEditFields(fields: Set<TaskFormField>) = dataStore.edit {
|
||||||
it[DEFAULT_EDIT_FIELDS] = fields.mapTo(mutableSetOf()) { field -> field.name }
|
it[DEFAULT_EDIT_FIELDS] = fields.mapTo(mutableSetOf()) { field -> field.name }
|
||||||
}
|
}
|
||||||
@@ -282,6 +327,9 @@ class SettingsPrefs @Inject constructor(
|
|||||||
val SYNC_INTERVAL = intPreferencesKey("sync_interval_minutes")
|
val SYNC_INTERVAL = intPreferencesKey("sync_interval_minutes")
|
||||||
val PUSH_ENABLED = booleanPreferencesKey("push_enabled")
|
val PUSH_ENABLED = booleanPreferencesKey("push_enabled")
|
||||||
val SMART_LIST_HIDDEN = stringSetPreferencesKey("smart_list_hidden")
|
val SMART_LIST_HIDDEN = stringSetPreferencesKey("smart_list_hidden")
|
||||||
|
val DEFAULT_START_TODAY = booleanPreferencesKey("default_start_today")
|
||||||
|
val SAVED_FILTERS = stringPreferencesKey("saved_filters")
|
||||||
|
val SAVED_FILTER_NEXT_ID = longPreferencesKey("saved_filter_next_id")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,6 +340,13 @@ class SettingsPrefs @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
private val reminderCodec = ReminderOverrideCodec.DEFAULT
|
private val reminderCodec = ReminderOverrideCodec.DEFAULT
|
||||||
|
|
||||||
/** `5,30` → [5, 30]; an empty string is an explicit "no reminder". */
|
/** `s5,30` → [5 before start, 30 before due]; an empty string is an explicit "no reminder". */
|
||||||
private fun parseMinutes(stored: String): List<Int> =
|
private fun parseLeads(stored: String): List<ReminderLead> =
|
||||||
stored.split(',').mapNotNull { it.trim().toIntOrNull()?.takeIf { m -> m >= 0 } }.normalizeReminders()
|
stored.split(',').mapNotNull { ReminderLead.parse(it)?.takeIf { lead -> lead.minutes >= 0 } }.normalizeReminders()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All-day defaults as whole days. Before 1.2 an all-day default could be hours
|
||||||
|
* before the all-day time; a lead that is not whole days now names a time of day.
|
||||||
|
*/
|
||||||
|
private fun List<ReminderLead>.inWholeDays(): List<ReminderLead> =
|
||||||
|
map { it.asTaskReminder().inWholeDays().asReminderLead() }.normalizeReminders()
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.reminders
|
package de.jeanlucmakiola.agendula.data.reminders
|
||||||
|
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.Settings
|
import de.jeanlucmakiola.agendula.data.prefs.Settings
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
|
import de.jeanlucmakiola.agendula.domain.AllDayReminder
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||||
import de.jeanlucmakiola.agendula.domain.calendarDate
|
import de.jeanlucmakiola.agendula.domain.calendarDate
|
||||||
import java.time.LocalTime
|
import java.time.LocalTime
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
@@ -36,31 +37,53 @@ internal object ReminderPlanner {
|
|||||||
// One reminder per *occurrence* and lead: a recurring series yields a row
|
// One reminder per *occurrence* and lead: a recurring series yields a row
|
||||||
// per occurrence, all sharing a taskId, so this is a Set rather than a
|
// per occurrence, all sharing a taskId, so this is a Set rather than a
|
||||||
// taskId-keyed Map.
|
// taskId-keyed Map.
|
||||||
.filter { !it.isClosed && it.due != null }
|
.filter { !it.isClosed && (it.due != null || it.start != null) }
|
||||||
.flatMap { task ->
|
.flatMap { task ->
|
||||||
// Reminders set on the task itself win — every one of them, not just
|
// Reminders set on the task itself win — every one of them;
|
||||||
// the one the editor shows; otherwise the task's list may override the
|
// otherwise the task's list may override the global lead, or opt
|
||||||
// global lead, or opt out entirely (override = null). All-day tasks
|
// out entirely (override = null). All-day tasks have their own
|
||||||
// have their own day-scale defaults.
|
// day-scale defaults.
|
||||||
val reminders = perTask[task.taskId].orEmpty().ifEmpty {
|
//
|
||||||
val leads = if (task.isAllDay) settings.allDayReminderLeadsFor(task.listId)
|
// ⚠️ Present-but-empty is "none", not "the default": the store's
|
||||||
else settings.reminderLeadsFor(task.listId)
|
// answer for a task whose alarms another client turned off. A
|
||||||
leads.map { TaskReminder(it) }
|
// default here would keep ringing for what the user switched off.
|
||||||
}
|
// A default counts only from a date the task has; its own reminders
|
||||||
reminders.map { reminder ->
|
// stand in each date for the other when the task lacks it.
|
||||||
// A stored reminder says what it counts back from. Ours are always
|
val own = perTask[task.taskId]
|
||||||
// before due, but an imported dmfs alarm or another client's can be
|
val reminders = own ?: settings.defaultRemindersFor(task.listId, task.isAllDay)
|
||||||
// before *start*.
|
.filter { (if (it.fromStart) task.start else task.due) != null }
|
||||||
val anchor = if (reminder.fromStart) task.start ?: task.due!! else task.due!!
|
val created = task.created?.toEpochMilliseconds()
|
||||||
|
reminders.mapNotNull { reminder ->
|
||||||
|
val anchor = if (reminder.fromStart) task.start ?: task.due!! else task.due ?: task.start!!
|
||||||
|
val trigger = triggerAt(
|
||||||
|
anchor,
|
||||||
|
task.isAllDay,
|
||||||
|
reminder.minutesBefore,
|
||||||
|
settings.allDayReminderMinuteOfDay,
|
||||||
|
zone,
|
||||||
|
)
|
||||||
|
// A default never rings for a moment before the task existed:
|
||||||
|
// that is a task saved after its default had passed, not a
|
||||||
|
// reminder missed while the device was off.
|
||||||
|
if (own == null && created != null && trigger < created) return@mapNotNull null
|
||||||
|
// Dismissed on another client already: firing it here within
|
||||||
|
// the missed-reminder grace would ring for something done with.
|
||||||
|
//
|
||||||
|
// ⚠️ An all-day reminder without a time of its own is compared
|
||||||
|
// from the start of its day, not from this device's time of
|
||||||
|
// day: other clients ring it at midnight, or at their own hour,
|
||||||
|
// so a dismissal at 07:00 must still silence the 09:00 this
|
||||||
|
// device would ring it at.
|
||||||
|
val earliest = if (task.isAllDay && reminder.allDay.minuteOfDay == null) {
|
||||||
|
triggerAt(anchor, true, reminder.minutesBefore, 0, zone)
|
||||||
|
} else {
|
||||||
|
trigger
|
||||||
|
}
|
||||||
|
val acknowledged = reminder.acknowledgedAt?.let { earliest <= it.toEpochMilliseconds() } == true
|
||||||
|
if (acknowledged) return@mapNotNull null
|
||||||
ScheduledReminder(
|
ScheduledReminder(
|
||||||
taskId = task.taskId,
|
taskId = task.taskId,
|
||||||
triggerAt = triggerAt(
|
triggerAt = trigger,
|
||||||
anchor,
|
|
||||||
task.isAllDay,
|
|
||||||
reminder.minutesBefore,
|
|
||||||
settings.allDayReminderMinuteOfDay,
|
|
||||||
zone,
|
|
||||||
),
|
|
||||||
occurrenceStart = task.occurrenceStart?.toEpochMilliseconds() ?: ScheduledReminder.NO_OCCURRENCE,
|
occurrenceStart = task.occurrenceStart?.toEpochMilliseconds() ?: ScheduledReminder.NO_OCCURRENCE,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -80,8 +103,9 @@ internal object ReminderPlanner {
|
|||||||
*
|
*
|
||||||
* ⚠️ An all-day anchor is UTC midnight of its date (see `AllDayTime.kt`), a
|
* ⚠️ An all-day anchor is UTC midnight of its date (see `AllDayTime.kt`), a
|
||||||
* storage convention and not a moment — firing off it rang at 02:00 in
|
* storage convention and not a moment — firing off it rang at 02:00 in
|
||||||
* Berlin and the evening before in New York. It is read as its calendar date
|
* Berlin and the evening before in New York. It is read as its calendar
|
||||||
* at [allDayMinuteOfDay] local time instead, and the lead counts back from that.
|
* date instead, and the lead as [TaskReminder.allDay]: whole days ring at
|
||||||
|
* [allDayMinuteOfDay] local time, anything else at the time it names.
|
||||||
*/
|
*/
|
||||||
fun triggerAt(
|
fun triggerAt(
|
||||||
anchor: Instant,
|
anchor: Instant,
|
||||||
@@ -90,12 +114,11 @@ internal object ReminderPlanner {
|
|||||||
allDayMinuteOfDay: Int,
|
allDayMinuteOfDay: Int,
|
||||||
zone: ZoneId,
|
zone: ZoneId,
|
||||||
): Long {
|
): Long {
|
||||||
val base = if (allDay) {
|
if (!allDay) return anchor.toEpochMilliseconds() - leadMinutes.coerceAtLeast(0) * 60_000L
|
||||||
val time = LocalTime.of(allDayMinuteOfDay / 60 % 24, allDayMinuteOfDay % 60)
|
val (days, minuteOfDay) = AllDayReminder.of(leadMinutes)
|
||||||
anchor.calendarDate(allDay = true).atTime(time).atZone(zone).toInstant().toEpochMilli()
|
val minute = minuteOfDay ?: allDayMinuteOfDay
|
||||||
} else {
|
val time = LocalTime.of(minute / 60 % 24, minute % 60)
|
||||||
anchor.toEpochMilliseconds()
|
return anchor.calendarDate(allDay = true).minusDays(days.toLong())
|
||||||
}
|
.atTime(time).atZone(zone).toInstant().toEpochMilli()
|
||||||
return base - leadMinutes.coerceAtLeast(0) * 60_000L
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.sync
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task lists that live on a server: making, renaming, recolouring and deleting
|
||||||
|
* them. The `full` flavor talks CalDAV; the `offline` one has no accounts, so
|
||||||
|
* every list is device-only and none of this is ever reached with a real one.
|
||||||
|
*/
|
||||||
|
interface RemoteLists {
|
||||||
|
|
||||||
|
/** Why a collection write did not happen, in a form the UI can translate. */
|
||||||
|
sealed interface Outcome {
|
||||||
|
data object Done : Outcome
|
||||||
|
|
||||||
|
/** The server said no, and will say no again. */
|
||||||
|
data class Refused(val code: Int) : Outcome
|
||||||
|
|
||||||
|
/** The server could not be reached. Worth another try. */
|
||||||
|
data object Unreachable : Outcome
|
||||||
|
|
||||||
|
/** This account cannot make collections at all — iCloud, Posteo, Google. */
|
||||||
|
data object Unsupported : Outcome
|
||||||
|
|
||||||
|
/** Ours is a read-only share; the write belongs to whoever owns it. */
|
||||||
|
data object ReadOnly : Outcome
|
||||||
|
|
||||||
|
/** The account is gone, stopped, or has no credential we can decrypt. */
|
||||||
|
data object NoAccount : Outcome
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The server answered something this call cannot make sense of.
|
||||||
|
*
|
||||||
|
* ⚠️ Not [Unreachable]. `CollectionOutcome` is one type across create,
|
||||||
|
* update and delete, so each of them has branches the other's method
|
||||||
|
* can return and its own cannot — and mapping those to [Unreachable]
|
||||||
|
* told someone sitting on wifi that they were offline. Unreachable is a
|
||||||
|
* claim about the network, and this is not one.
|
||||||
|
*/
|
||||||
|
data object Unexpected : Outcome
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The accounts a new list may be created on. */
|
||||||
|
suspend fun creatableAccounts(): List<AccountEntity>
|
||||||
|
|
||||||
|
/** Makes a collection on [accountId]'s home set and a row pointing at it. */
|
||||||
|
suspend fun create(accountId: Long, name: String, color: Int): Outcome
|
||||||
|
|
||||||
|
suspend fun rename(listId: Long, name: String, color: Int): Outcome
|
||||||
|
|
||||||
|
suspend fun delete(listId: Long): Outcome
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.sync
|
||||||
|
|
||||||
|
/** Asks for an account to be synced now. A no-op in the `offline` flavor. */
|
||||||
|
fun interface SyncRequests {
|
||||||
|
fun syncNow(accountName: String)
|
||||||
|
}
|
||||||
+169
-33
@@ -1,5 +1,7 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.tasks
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||||
|
import android.content.ContentProviderOperation
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import android.content.ContentUris
|
import android.content.ContentUris
|
||||||
import android.content.ContentValues
|
import android.content.ContentValues
|
||||||
@@ -19,6 +21,7 @@ import de.jeanlucmakiola.agendula.domain.Task
|
|||||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
import de.jeanlucmakiola.agendula.domain.export.ExportTask
|
import de.jeanlucmakiola.agendula.domain.export.ExportTask
|
||||||
|
import de.jeanlucmakiola.agendula.domain.normalizeTags
|
||||||
import de.jeanlucmakiola.agendula.domain.recurrence.ExpansionWindow
|
import de.jeanlucmakiola.agendula.domain.recurrence.ExpansionWindow
|
||||||
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander
|
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander
|
||||||
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
|
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
|
||||||
@@ -83,6 +86,27 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
override fun subtasks(parentTaskId: Long): List<Task> =
|
override fun subtasks(parentTaskId: Long): List<Task> =
|
||||||
queryInstances("${Tasks.PARENT_ID} = ?", arrayOf(parentTaskId.toString()))
|
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> {
|
private fun queryInstances(selection: String?, args: Array<String>?): List<Task> {
|
||||||
val uri = TasksContract.instancesUri(authority())
|
val uri = TasksContract.instancesUri(authority())
|
||||||
// projection = null (all columns): the instances view differs across
|
// projection = null (all columns): the instances view differs across
|
||||||
@@ -90,11 +114,14 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
// `is_recurring` / `distance_from_current`). The by-name mapper reads
|
// `is_recurring` / `distance_from_current`). The by-name mapper reads
|
||||||
// whatever is present and nulls the rest; the repository re-sorts, so we
|
// whatever is present and nulls the rest; the repository re-sorts, so we
|
||||||
// don't depend on a provider sort column either.
|
// don't depend on a provider sort column either.
|
||||||
return resolver.query(uri, null, selection, args, null)
|
val tasks = resolver.query(uri, null, selection, args, null)
|
||||||
?.use { c ->
|
?.use { c ->
|
||||||
val reader = CursorColumnReader(c)
|
val reader = CursorColumnReader(c)
|
||||||
buildList { while (c.moveToNext()) add(TaskMapper.task(reader)) }
|
buildList { while (c.moveToNext()) add(TaskMapper.task(reader)) }
|
||||||
} ?: emptyList()
|
} ?: emptyList()
|
||||||
|
if (tasks.isEmpty()) return tasks
|
||||||
|
val tags = categories(tasks.map { it.taskId })
|
||||||
|
return tasks.map { task -> tags[task.taskId]?.let { task.copy(tags = it) } ?: task }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun exportTasks(listId: Long): List<ExportTask> {
|
override fun exportTasks(listId: Long): List<ExportTask> {
|
||||||
@@ -102,7 +129,7 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
// table's shape varies across provider versions, and the by-name mapper
|
// table's shape varies across provider versions, and the by-name mapper
|
||||||
// reads what's there.
|
// reads what's there.
|
||||||
val uri = TasksContract.tasksUri(authority())
|
val uri = TasksContract.tasksUri(authority())
|
||||||
return resolver.query(
|
val tasks = resolver.query(
|
||||||
uri,
|
uri,
|
||||||
null,
|
null,
|
||||||
// _deleted marks a row awaiting a sync round-trip. It's gone as far as
|
// _deleted marks a row awaiting a sync round-trip. It's gone as far as
|
||||||
@@ -115,24 +142,58 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
val reader = CursorColumnReader(c)
|
val reader = CursorColumnReader(c)
|
||||||
buildList { while (c.moveToNext()) add(TaskMapper.exportTask(reader)) }
|
buildList { while (c.moveToNext()) add(TaskMapper.exportTask(reader)) }
|
||||||
} ?: emptyList()
|
} ?: emptyList()
|
||||||
|
if (tasks.isEmpty()) return tasks
|
||||||
|
val tags = categories(tasks.map { it.taskId })
|
||||||
|
return tasks.map { task -> tags[task.taskId]?.let { task.copy(tags = it) } ?: task }
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- writes ---------------------------------------------------------------
|
// --- writes ---------------------------------------------------------------
|
||||||
|
|
||||||
override fun insertTask(form: TaskForm): Long {
|
override fun insertTask(form: TaskForm, reminders: List<TaskReminder>?): Long {
|
||||||
val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id)
|
val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id)
|
||||||
val uri = resolver.insert(TasksContract.tasksUri(authority()), values.toContentValues())
|
// One batch, so a refused tag or alarm leaves no task behind for a retry
|
||||||
?: throw TaskWriteFailedException("insert task")
|
// to duplicate.
|
||||||
return uri.lastPathSegment?.toLongOrNull() ?: throw TaskWriteFailedException("insert task: no id")
|
val authority = authority()
|
||||||
|
val ops = arrayListOf(
|
||||||
|
ContentProviderOperation.newInsert(TasksContract.tasksUri(authority))
|
||||||
|
.withValues(values.toContentValues())
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
normalizeTags(form.tags).forEach { tag ->
|
||||||
|
ops += ContentProviderOperation.newInsert(TasksContract.propertiesUri(authority))
|
||||||
|
.withValues(TaskWriteMapper.categoryValues(0, tag).toContentValues().apply { remove(Properties.TASK_ID) })
|
||||||
|
.withValueBackReference(Properties.TASK_ID, 0)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
reminders.orEmpty().forEach { reminder ->
|
||||||
|
ops += ContentProviderOperation.newInsert(TasksContract.propertiesUri(authority))
|
||||||
|
.withValues(
|
||||||
|
TaskWriteMapper.alarmValues(0, reminder.minutesBefore, reminder.fromStart)
|
||||||
|
.toContentValues().apply { remove(Properties.TASK_ID) },
|
||||||
|
)
|
||||||
|
.withValueBackReference(Properties.TASK_ID, 0)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
val results = runCatching { resolver.applyBatch(authority, ops) }
|
||||||
|
.getOrElse { throw TaskWriteFailedException("insert task", it) }
|
||||||
|
return results.firstOrNull()?.uri?.lastPathSegment?.toLongOrNull()
|
||||||
|
?: throw TaskWriteFailedException("insert task: no id")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateTask(taskId: Long, form: TaskForm) {
|
override fun updateTask(taskId: Long, form: TaskForm, reminders: List<TaskReminder>?) {
|
||||||
val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id)
|
val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id)
|
||||||
val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
|
val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
|
||||||
if (rows == 0) throw TaskWriteFailedException("update task $taskId")
|
if (rows == 0) throw TaskWriteFailedException("update task $taskId")
|
||||||
|
setCategories(taskId, form.tags)
|
||||||
|
writeReminders(taskId, reminders)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) {
|
private fun writeReminders(taskId: Long, reminders: List<TaskReminder>?) {
|
||||||
|
if (reminders == null) return
|
||||||
|
setReminders(taskId, reminders)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) {
|
||||||
val occurrence = task(seriesId, occurrenceStart)
|
val occurrence = task(seriesId, occurrenceStart)
|
||||||
val anchors = resolver.query(
|
val anchors = resolver.query(
|
||||||
taskUri(authority(), seriesId), arrayOf(Tasks.DTSTART, Tasks.DUE), null, null, null,
|
taskUri(authority(), seriesId), arrayOf(Tasks.DTSTART, Tasks.DUE), null, null, null,
|
||||||
@@ -151,6 +212,8 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
(Tasks.DUE to shifted(anchors?.second, occurrence?.due, form.due))
|
(Tasks.DUE to shifted(anchors?.second, occurrence?.due, form.due))
|
||||||
val rows = resolver.update(taskUri(authority(), seriesId), values.toContentValues(), null, null)
|
val rows = resolver.update(taskUri(authority(), seriesId), values.toContentValues(), null, null)
|
||||||
if (rows == 0) throw TaskWriteFailedException("update series $seriesId")
|
if (rows == 0) throw TaskWriteFailedException("update series $seriesId")
|
||||||
|
setCategories(seriesId, form.tags)
|
||||||
|
writeReminders(seriesId, reminders)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -159,10 +222,10 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
* task. Per-occurrence edits past the split point are dropped rather than
|
* task. Per-occurrence edits past the split point are dropped rather than
|
||||||
* carried over — the provider offers no way to re-parent an exception.
|
* carried over — the provider offers no way to re-parent an exception.
|
||||||
*/
|
*/
|
||||||
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm): Long {
|
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?): Long {
|
||||||
val spec = seriesSpec(seriesId) ?: throw TaskWriteFailedException("split series $seriesId")
|
val spec = seriesSpec(seriesId) ?: throw TaskWriteFailedException("split series $seriesId")
|
||||||
if (spec.rrule == null || occurrenceStart <= spec.anchor) {
|
if (spec.rrule == null || occurrenceStart <= spec.anchor) {
|
||||||
updateSeries(seriesId, occurrenceStart, form)
|
updateSeries(seriesId, occurrenceStart, form, reminders)
|
||||||
return seriesId
|
return seriesId
|
||||||
}
|
}
|
||||||
// COUNT counts before EXDATE is applied (RFC 5545 §3.8.5.3).
|
// COUNT counts before EXDATE is applied (RFC 5545 §3.8.5.3).
|
||||||
@@ -172,12 +235,12 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
).size
|
).size
|
||||||
endSeriesBefore(seriesId, spec, occurrenceStart)
|
endSeriesBefore(seriesId, spec, occurrenceStart)
|
||||||
val rule = form.rrule?.let { if (it == spec.rrule) TaskFormWriter.remainingCount(it, spent) else it }
|
val rule = form.rrule?.let { if (it == spec.rrule) TaskFormWriter.remainingCount(it, spent) else it }
|
||||||
return insertTask(form.copy(rrule = rule, percentComplete = null))
|
return insertTask(form.copy(rrule = rule, percentComplete = null), reminders)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) {
|
override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) {
|
||||||
val spec = seriesSpec(seriesId) ?: return
|
val spec = seriesSpec(seriesId) ?: return
|
||||||
if (spec.rrule == null || occurrenceStart <= spec.anchor) return deleteTask(seriesId)
|
if (spec.rrule == null || occurrenceStart <= spec.anchor) return deleteWithSubtasks(seriesId)
|
||||||
endSeriesBefore(seriesId, spec, occurrenceStart)
|
endSeriesBefore(seriesId, spec, occurrenceStart)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,13 +301,49 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
resolver.delete(TasksContract.instanceUri(authority(), instanceId), null, null)
|
resolver.delete(TasksContract.instanceUri(authority(), instanceId), null, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) {
|
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) {
|
||||||
|
if (reminders == null) return writeInstance(taskId, occurrenceStart, form)
|
||||||
|
// ⚠️ Written to the series, and then put back. Forking an occurrence
|
||||||
|
// copies the task's *current* properties onto the new override row, so
|
||||||
|
// setting the alarm beforehand is the only way to carry it across — and
|
||||||
|
// leaving it there would silently give every other occurrence in the
|
||||||
|
// series the reminder the user set on one of them.
|
||||||
|
val seriesReminders = alarmRows(taskId).map { it.second }
|
||||||
|
writeReminders(taskId, reminders)
|
||||||
|
try {
|
||||||
|
writeInstance(taskId, occurrenceStart, form)
|
||||||
|
} finally {
|
||||||
|
setReminders(taskId, seriesReminders)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) {
|
||||||
val instanceId = instanceIdFor(taskId, occurrenceStart)
|
val instanceId = instanceIdFor(taskId, occurrenceStart)
|
||||||
?: throw TaskWriteFailedException("update instance $taskId@$occurrenceStart: no such occurrence")
|
?: throw TaskWriteFailedException("update instance $taskId@$occurrenceStart: no such occurrence")
|
||||||
val values = TaskWriteMapper.instanceValues(form, ZoneId.systemDefault().id)
|
val values = TaskWriteMapper.instanceValues(form, ZoneId.systemDefault().id)
|
||||||
val uri = TasksContract.instanceUri(authority(), instanceId)
|
val uri = TasksContract.instanceUri(authority(), instanceId)
|
||||||
val rows = resolver.update(uri, values.toContentValues(), null, null)
|
// The fork copies the series' properties onto the override, so the tags
|
||||||
if (rows == 0) throw TaskWriteFailedException("update instance $instanceId")
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -271,21 +370,6 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) {
|
|
||||||
val uri = TasksContract.propertiesUri(authority())
|
|
||||||
// Replace rather than update: the provider's AlarmHandler re-validates the
|
|
||||||
// whole row on every update, so a partial edit throws. Only the editable
|
|
||||||
// (last before-due) row goes — a start-relative or additional alarm
|
|
||||||
// another client wrote is not ours to drop.
|
|
||||||
alarmRows(taskId).lastOrNull { !it.second.fromStart }?.let { (propertyId, _) ->
|
|
||||||
resolver.delete(uri, "${Properties.PROPERTY_ID} = ?", arrayOf(propertyId.toString()))
|
|
||||||
}
|
|
||||||
if (minutesBeforeDue != null) {
|
|
||||||
resolver.insert(uri, TaskWriteMapper.alarmValues(taskId, minutesBeforeDue).toContentValues())
|
|
||||||
?: throw TaskWriteFailedException("set alarm for task $taskId")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) {
|
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) {
|
||||||
val uri = TasksContract.propertiesUri(authority())
|
val uri = TasksContract.propertiesUri(authority())
|
||||||
resolver.delete(
|
resolver.delete(
|
||||||
@@ -302,9 +386,6 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
override fun reminders(): Map<Long, List<TaskReminder>> =
|
override fun reminders(): Map<Long, List<TaskReminder>> =
|
||||||
readAlarms(null).groupBy({ it.first }) { it.third }
|
readAlarms(null).groupBy({ it.first }) { it.third }
|
||||||
|
|
||||||
override fun alarms(): Map<Long, TaskReminder> =
|
|
||||||
reminders().mapNotNull { (id, list) -> list.editable()?.let { id to it } }.toMap()
|
|
||||||
|
|
||||||
/** `(property_id, reminder)` for [taskId]'s alarm rows, in stored order. */
|
/** `(property_id, reminder)` for [taskId]'s alarm rows, in stored order. */
|
||||||
private fun alarmRows(taskId: Long): List<Pair<Long, TaskReminder>> =
|
private fun alarmRows(taskId: Long): List<Pair<Long, TaskReminder>> =
|
||||||
readAlarms(taskId).map { it.second to it.third }
|
readAlarms(taskId).map { it.second to it.third }
|
||||||
@@ -348,6 +429,58 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
} ?: emptyList()
|
} ?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Tags by task id for [taskIds], in stored order. */
|
||||||
|
private fun categories(taskIds: Collection<Long>): Map<Long, List<String>> {
|
||||||
|
val rows = taskIds.distinct().chunked(MAX_SQL_ARGS - 1).flatMap { chunk ->
|
||||||
|
val selection = "${Properties.MIMETYPE} = ? AND ${Properties.TASK_ID} IN (${chunk.joinToString(",") { "?" }})"
|
||||||
|
val args = (listOf(TasksContract.Category.MIMETYPE) + chunk.map(Long::toString)).toTypedArray()
|
||||||
|
resolver.query(
|
||||||
|
TasksContract.propertiesUri(authority()),
|
||||||
|
arrayOf(Properties.TASK_ID, TasksContract.Category.NAME),
|
||||||
|
selection,
|
||||||
|
args,
|
||||||
|
Properties.PROPERTY_ID,
|
||||||
|
)?.use { c ->
|
||||||
|
val reader = CursorColumnReader(c)
|
||||||
|
buildList {
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
val id = reader.getLong(Properties.TASK_ID) ?: continue
|
||||||
|
val name = reader.getString(TasksContract.Category.NAME) ?: continue
|
||||||
|
add(id to name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: emptyList()
|
||||||
|
}
|
||||||
|
return rows.groupBy({ it.first }, { it.second }).mapValues { normalizeTags(it.value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces [taskId]'s category rows in one batch, so a refused insert cannot
|
||||||
|
* leave half the tags, and leaves them alone when nothing changed so the task
|
||||||
|
* isn't dirtied.
|
||||||
|
*/
|
||||||
|
private fun setCategories(taskId: Long, tags: List<String>) {
|
||||||
|
val wanted = normalizeTags(tags)
|
||||||
|
if (categories(listOf(taskId))[taskId].orEmpty() == wanted) return
|
||||||
|
val authority = authority()
|
||||||
|
val uri = TasksContract.propertiesUri(authority)
|
||||||
|
val ops = arrayListOf(
|
||||||
|
ContentProviderOperation.newDelete(uri)
|
||||||
|
.withSelection(
|
||||||
|
"${Properties.TASK_ID} = ? AND ${Properties.MIMETYPE} = ?",
|
||||||
|
arrayOf(taskId.toString(), TasksContract.Category.MIMETYPE),
|
||||||
|
)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
wanted.forEach {
|
||||||
|
ops += ContentProviderOperation.newInsert(uri)
|
||||||
|
.withValues(TaskWriteMapper.categoryValues(taskId, it).toContentValues())
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
runCatching { resolver.applyBatch(authority, ops) }
|
||||||
|
.getOrElse { throw TaskWriteFailedException("set categories for task $taskId", it) }
|
||||||
|
}
|
||||||
|
|
||||||
override fun setCancelled(taskId: Long, cancelled: Boolean) {
|
override fun setCancelled(taskId: Long, cancelled: Boolean) {
|
||||||
val rows = resolver.update(taskUri(authority(), taskId), cancelValues(cancelled).toContentValues(), null, null)
|
val rows = resolver.update(taskUri(authority(), taskId), cancelValues(cancelled).toContentValues(), null, null)
|
||||||
if (rows == 0) throw TaskWriteFailedException("cancel task $taskId")
|
if (rows == 0) throw TaskWriteFailedException("cancel task $taskId")
|
||||||
@@ -460,3 +593,6 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
return cv
|
return cv
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** SQLite's default bound-argument limit on older Android releases. */
|
||||||
|
private const val MAX_SQL_ARGS = 999
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ class ProviderUnavailableException :
|
|||||||
IllegalStateException("No tasks provider installed")
|
IllegalStateException("No tasks provider installed")
|
||||||
|
|
||||||
/** A ContentResolver write returned no URI or affected no rows. */
|
/** A ContentResolver write returned no URI or affected no rows. */
|
||||||
class TaskWriteFailedException(operation: String) :
|
class TaskWriteFailedException(operation: String, cause: Throwable? = null) :
|
||||||
RuntimeException("Task write failed: $operation")
|
RuntimeException("Task write failed: $operation", cause)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The task changed (e.g. a DAVx5 sync or another app) since the edit form loaded
|
* The task changed (e.g. a DAVx5 sync or another app) since the edit form loaded
|
||||||
|
|||||||
+12
-10
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.tasks
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
|
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
@@ -34,23 +35,24 @@ class ModeRoutingTasksDataSource(
|
|||||||
override fun tasks(query: TaskQuery): List<Task> = active().tasks(query)
|
override fun tasks(query: TaskQuery): List<Task> = active().tasks(query)
|
||||||
override fun task(taskId: Long): Task? = active().task(taskId)
|
override fun task(taskId: Long): Task? = active().task(taskId)
|
||||||
override fun task(taskId: Long, occurrenceStart: Instant?): Task? = active().task(taskId, occurrenceStart)
|
override fun task(taskId: Long, occurrenceStart: Instant?): Task? = active().task(taskId, occurrenceStart)
|
||||||
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) =
|
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) =
|
||||||
active().updateSeries(seriesId, occurrenceStart, form)
|
active().updateSeries(seriesId, occurrenceStart, form, reminders)
|
||||||
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm): Long =
|
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?): Long =
|
||||||
active().splitSeries(seriesId, occurrenceStart, form)
|
active().splitSeries(seriesId, occurrenceStart, form, reminders)
|
||||||
override fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant) =
|
override fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant) =
|
||||||
active().deleteOccurrence(seriesId, occurrenceStart)
|
active().deleteOccurrence(seriesId, occurrenceStart)
|
||||||
override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) =
|
override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) =
|
||||||
active().deleteFollowing(seriesId, occurrenceStart)
|
active().deleteFollowing(seriesId, occurrenceStart)
|
||||||
override fun subtasks(parentTaskId: Long): List<Task> = active().subtasks(parentTaskId)
|
override fun subtasks(parentTaskId: Long): List<Task> = active().subtasks(parentTaskId)
|
||||||
override fun insertTask(form: TaskForm): Long = active().insertTask(form)
|
override fun subtaskIds(parentTaskId: Long): List<Long> = active().subtaskIds(parentTaskId)
|
||||||
override fun updateTask(taskId: Long, form: TaskForm) = active().updateTask(taskId, form)
|
override fun deleteWithSubtasks(taskId: Long) = active().deleteWithSubtasks(taskId)
|
||||||
|
override fun insertTask(form: TaskForm, reminders: List<TaskReminder>?): Long = active().insertTask(form, reminders)
|
||||||
|
override fun updateTask(taskId: Long, form: TaskForm, reminders: List<TaskReminder>?) =
|
||||||
|
active().updateTask(taskId, form, reminders)
|
||||||
|
|
||||||
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) =
|
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) =
|
||||||
active().updateInstance(taskId, occurrenceStart, form)
|
active().updateInstance(taskId, occurrenceStart, form, reminders)
|
||||||
|
|
||||||
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) = active().setAlarm(taskId, minutesBeforeDue)
|
|
||||||
override fun alarms(): Map<Long, TaskReminder> = active().alarms()
|
|
||||||
override fun reminders(): Map<Long, List<TaskReminder>> = active().reminders()
|
override fun reminders(): Map<Long, List<TaskReminder>> = active().reminders()
|
||||||
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) =
|
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) =
|
||||||
active().setReminders(taskId, reminders)
|
active().setReminders(taskId, reminders)
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.agendula.data.prefs.Settings
|
||||||
|
import de.jeanlucmakiola.agendula.data.reminders.ReminderPlanner
|
||||||
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||||
|
import java.time.ZoneId
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
internal object StoredReminders {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reminders to store for [form], given the task as it was.
|
||||||
|
*
|
||||||
|
* A date the task gets in this write brings the list's or the app's
|
||||||
|
* default for it *as the task's own reminders*. The default is this
|
||||||
|
* device's setting, which nothing else can see; stored on the task, it
|
||||||
|
* travels with it to the server and fires on every client. The cost is
|
||||||
|
* tasks.org's: changing the default later does not reach the tasks that
|
||||||
|
* already have it.
|
||||||
|
*
|
||||||
|
* A task getting its first date takes the defaults only when the form
|
||||||
|
* names no reminder; one that already had a date takes those of the new
|
||||||
|
* date that no reminder of the form counts from. Otherwise the form is
|
||||||
|
* kept, so an edit that clears every reminder means "use the default
|
||||||
|
* here" as before.
|
||||||
|
*
|
||||||
|
* The form's reminders are already of its kind, timed or all-day — see
|
||||||
|
* [switched]. Reminders the editor [chose][TaskForm.remindersChosen] are
|
||||||
|
* kept as they are: it showed these defaults already.
|
||||||
|
*/
|
||||||
|
fun of(form: TaskForm, current: Task?, settings: Settings, now: Long, zone: ZoneId): List<TaskReminder> {
|
||||||
|
val wanted = form.reminders.map { it.lead }.distinct()
|
||||||
|
if (form.remindersChosen) return wanted
|
||||||
|
return withDefaults(form, current?.start, current?.due, settings, now, zone)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [of] for a task whose dates were [start] and [due], whatever the form chose. */
|
||||||
|
fun withDefaults(
|
||||||
|
form: TaskForm,
|
||||||
|
start: Instant?,
|
||||||
|
due: Instant?,
|
||||||
|
settings: Settings,
|
||||||
|
now: Long,
|
||||||
|
zone: ZoneId,
|
||||||
|
): List<TaskReminder> {
|
||||||
|
val wanted = form.reminders.map { it.lead }.distinct()
|
||||||
|
val had = anchorsOf(start, due)
|
||||||
|
val gained = anchorsOf(form.start, form.due) - had
|
||||||
|
if (gained.isEmpty()) return wanted
|
||||||
|
if (had.isEmpty() || wanted.isEmpty()) return wanted.ifEmpty { defaultsFor(form, settings, now, zone) }
|
||||||
|
val named = wanted.map { it.fromStart }.toSet()
|
||||||
|
return wanted + defaultsFor(form, settings, now, zone).filter { it.fromStart in gained && it.fromStart !in named }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [reminders] for [form] once it has switched between timed and all-day.
|
||||||
|
* Reminders that are exactly the old kind's default become the new kind's;
|
||||||
|
* the rest keep what still means something: whole days, rounded to them on
|
||||||
|
* an all-day task.
|
||||||
|
*/
|
||||||
|
fun switched(
|
||||||
|
reminders: List<TaskReminder>,
|
||||||
|
form: TaskForm,
|
||||||
|
settings: Settings,
|
||||||
|
now: Long,
|
||||||
|
zone: ZoneId,
|
||||||
|
): List<TaskReminder> {
|
||||||
|
if (reminders.isEmpty()) return reminders
|
||||||
|
if (reminders.toSet() == settings.defaultRemindersFor(form.listId, !form.isAllDay).toSet()) {
|
||||||
|
return defaultsFor(form, settings, now, zone)
|
||||||
|
}
|
||||||
|
val kept = if (form.isAllDay) {
|
||||||
|
reminders.map { it.inWholeDays() }
|
||||||
|
} else {
|
||||||
|
reminders.filter { it.allDay.minuteOfDay == null }
|
||||||
|
}
|
||||||
|
return kept.distinct().ifEmpty { defaultsFor(form, settings, now, zone) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The dates a reminder can count from, as [TaskReminder.fromStart]. */
|
||||||
|
private fun anchorsOf(start: Any?, due: Any?): Set<Boolean> =
|
||||||
|
setOfNotNull(true.takeIf { start != null }, false.takeIf { due != null })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The defaults [form] takes: those counting from a date it has. A one-off
|
||||||
|
* task leaves out what is already past — a start of "now" would ring at once.
|
||||||
|
*/
|
||||||
|
private fun defaultsFor(form: TaskForm, settings: Settings, now: Long, zone: ZoneId): List<TaskReminder> =
|
||||||
|
settings.defaultRemindersFor(form.listId, form.isAllDay).filter { reminder ->
|
||||||
|
val anchor = (if (reminder.fromStart) form.start else form.due) ?: return@filter false
|
||||||
|
form.rrule != null || ReminderPlanner.triggerAt(
|
||||||
|
anchor,
|
||||||
|
form.isAllDay,
|
||||||
|
reminder.minutesBefore,
|
||||||
|
settings.allDayReminderMinuteOfDay,
|
||||||
|
zone,
|
||||||
|
) >= now
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -113,6 +113,12 @@ object TaskWriteMapper {
|
|||||||
Alarm.ALARM_TYPE to Alarm.TYPE_MESSAGE,
|
Alarm.ALARM_TYPE to Alarm.TYPE_MESSAGE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun categoryValues(taskId: Long, name: String): Map<String, Any?> = mapOf(
|
||||||
|
Properties.TASK_ID to taskId,
|
||||||
|
Properties.MIMETYPE to TasksContract.Category.MIMETYPE,
|
||||||
|
TasksContract.Category.NAME to name,
|
||||||
|
)
|
||||||
|
|
||||||
/** The user-owned columns of a list — what an edit is allowed to change. */
|
/** The user-owned columns of a list — what an edit is allowed to change. */
|
||||||
fun listValues(name: String, color: Int): Map<String, Any?> = mapOf(
|
fun listValues(name: String, color: Int): Map<String, Any?> = mapOf(
|
||||||
Lists.NAME to name.trim(),
|
Lists.NAME to name.trim(),
|
||||||
|
|||||||
@@ -86,6 +86,12 @@ object TasksContract {
|
|||||||
|
|
||||||
const val UID = "_uid"
|
const val UID = "_uid"
|
||||||
const val DELETED = "_deleted"
|
const val DELETED = "_deleted"
|
||||||
|
|
||||||
|
const val HAS_ALARMS = "has_alarms"
|
||||||
|
const val ORIGINAL_INSTANCE_ALLDAY = "original_instance_allday"
|
||||||
|
const val LIST_OWNER = "list_owner"
|
||||||
|
const val LIST_ACCESS_LEVEL = "list_access_level"
|
||||||
|
const val VISIBLE = "visible"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -103,6 +109,7 @@ object TasksContract {
|
|||||||
const val INSTANCE_DUE_SORTING = "instance_due_sorting"
|
const val INSTANCE_DUE_SORTING = "instance_due_sorting"
|
||||||
const val DISTANCE_FROM_CURRENT = "distance_from_current"
|
const val DISTANCE_FROM_CURRENT = "distance_from_current"
|
||||||
const val IS_RECURRING = "is_recurring"
|
const val IS_RECURRING = "is_recurring"
|
||||||
|
const val INSTANCE_DURATION = "instance_duration"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The occurrence's `RECURRENCE-ID` — the time this occurrence was
|
* The occurrence's `RECURRENCE-ID` — the time this occurrence was
|
||||||
@@ -118,6 +125,10 @@ object TasksContract {
|
|||||||
const val PROPERTY_ID = "property_id"
|
const val PROPERTY_ID = "property_id"
|
||||||
const val TASK_ID = "task_id"
|
const val TASK_ID = "task_id"
|
||||||
const val MIMETYPE = "mimetype"
|
const val MIMETYPE = "mimetype"
|
||||||
|
const val DATA0 = "data0"
|
||||||
|
const val DATA1 = "data1"
|
||||||
|
const val DATA2 = "data2"
|
||||||
|
const val DATA3 = "data3"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -154,6 +165,21 @@ object TasksContract {
|
|||||||
const val TYPE_MESSAGE = 1
|
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()
|
fun propertiesUri(authority: String): Uri = "content://$authority/${Properties.PATH}".toUri()
|
||||||
|
|
||||||
// --- status values (TaskColumns.STATUS_*) --------------------------------
|
// --- status values (TaskColumns.STATUS_*) --------------------------------
|
||||||
|
|||||||
@@ -3,26 +3,9 @@ package de.jeanlucmakiola.agendula.data.tasks
|
|||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
|
||||||
* A stored reminder: how long before, and what it counts back from.
|
|
||||||
*
|
|
||||||
* [fromStart] matters because both stores can hold a `START`-referenced alarm —
|
|
||||||
* the dmfs import preserves one, and an external provider's other clients write
|
|
||||||
* them — while Agendula's own UI only ever sets a before-due lead. Collapsing it
|
|
||||||
* to a number here is what silently fired those reminders off the wrong anchor.
|
|
||||||
*/
|
|
||||||
data class TaskReminder(val minutesBefore: Int, val fromStart: Boolean = false)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The one reminder Agendula's single-reminder editor shows and writes: the last
|
|
||||||
* before-due one, which is where a write lands, so it stays the same one across
|
|
||||||
* saves. Anything else a task carries — a start-relative alarm, or the extra ones
|
|
||||||
* another client or the dmfs import brought — is left to ride along.
|
|
||||||
*/
|
|
||||||
fun List<TaskReminder>.editable(): TaskReminder? = lastOrNull { !it.fromStart }
|
|
||||||
|
|
||||||
/** What to fetch from the provider. Smart-list date logic is applied above this. */
|
/** What to fetch from the provider. Smart-list date logic is applied above this. */
|
||||||
data class TaskQuery(
|
data class TaskQuery(
|
||||||
val listId: Long? = null,
|
val listId: Long? = null,
|
||||||
@@ -46,8 +29,38 @@ interface TasksDataSource {
|
|||||||
fun task(taskId: Long, occurrenceStart: Instant?): Task? = task(taskId)
|
fun task(taskId: Long, occurrenceStart: Instant?): Task? = task(taskId)
|
||||||
fun subtasks(parentTaskId: Long): List<Task>
|
fun subtasks(parentTaskId: Long): List<Task>
|
||||||
|
|
||||||
fun insertTask(form: TaskForm): Long
|
/**
|
||||||
fun updateTask(taskId: Long, form: TaskForm)
|
* The task rows directly under [parentTaskId]: one id per subtask, its series
|
||||||
|
* master when it recurs, whether or not any occurrence falls in the window
|
||||||
|
* [subtasks] expands.
|
||||||
|
*/
|
||||||
|
fun subtaskIds(parentTaskId: Long): List<Long> =
|
||||||
|
subtasks(parentTaskId).map { it.seriesId ?: it.taskId }.distinct().filter { it != parentTaskId }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete [taskId] and its subtasks, all the way down; left behind they would
|
||||||
|
* hang off a tombstone. Each task once, so a parent loop an import brought in
|
||||||
|
* ends instead of recursing forever.
|
||||||
|
*/
|
||||||
|
fun deleteWithSubtasks(taskId: Long) {
|
||||||
|
val seen = HashSet<Long>()
|
||||||
|
fun delete(id: Long) {
|
||||||
|
if (!seen.add(id)) return
|
||||||
|
subtaskIds(id).forEach(::delete)
|
||||||
|
deleteTask(id)
|
||||||
|
}
|
||||||
|
delete(taskId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The write paths take the task's reminders in the same write as the task
|
||||||
|
* itself, so a sync can never upload one without the other.
|
||||||
|
*
|
||||||
|
* Each takes the task's whole set of [reminders], before-start and
|
||||||
|
* before-due alike, and `null` leaves the stored ones alone.
|
||||||
|
*/
|
||||||
|
fun insertTask(form: TaskForm, reminders: List<TaskReminder>? = null): Long
|
||||||
|
fun updateTask(taskId: Long, form: TaskForm, reminders: List<TaskReminder>? = null)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a single occurrence of a recurring task, addressed by the task row and
|
* Update a single occurrence of a recurring task, addressed by the task row and
|
||||||
@@ -60,27 +73,19 @@ interface TasksDataSource {
|
|||||||
* instance row id keeps this seam independent of any one store's row
|
* instance row id keeps this seam independent of any one store's row
|
||||||
* numbering; External mode maps it back to an instance row itself.
|
* numbering; External mode maps it back to an instance row itself.
|
||||||
*/
|
*/
|
||||||
fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm)
|
fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>? = null)
|
||||||
/**
|
|
||||||
* Set (or clear, with `null`) the task's [editable] reminder lead, stored as an
|
|
||||||
* Alarm property row. Every other reminder on the task is left untouched. The
|
|
||||||
* provider never fires it — [de.jeanlucmakiola.agendula.data.reminders
|
|
||||||
* .ReminderScheduler] does — but persisting it here is what syncs the lead and
|
|
||||||
* shares it with other OpenTasks clients.
|
|
||||||
*/
|
|
||||||
fun setAlarm(taskId: Long, minutesBeforeDue: Int?)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Edit the whole series [seriesId] through its occurrence at [occurrenceStart]:
|
* Edit the whole series [seriesId] through its occurrence at [occurrenceStart]:
|
||||||
* content from [form], and the series moved by however far that occurrence moved.
|
* content from [form], and the series moved by however far that occurrence moved.
|
||||||
*/
|
*/
|
||||||
fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm)
|
fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>? = null)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "This and following": end [seriesId] before [occurrenceStart] and continue
|
* "This and following": end [seriesId] before [occurrenceStart] and continue
|
||||||
* from there as a new series built from [form]. Returns the new series' id.
|
* from there as a new series built from [form]. Returns the new series' id.
|
||||||
*/
|
*/
|
||||||
fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm): Long
|
fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>? = null): Long
|
||||||
|
|
||||||
/** Delete one generated occurrence of [seriesId] (an `EXDATE`). */
|
/** Delete one generated occurrence of [seriesId] (an `EXDATE`). */
|
||||||
fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant)
|
fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant)
|
||||||
@@ -89,14 +94,15 @@ interface TasksDataSource {
|
|||||||
fun deleteFollowing(seriesId: Long, occurrenceStart: Instant)
|
fun deleteFollowing(seriesId: Long, occurrenceStart: Instant)
|
||||||
|
|
||||||
|
|
||||||
/** Replace every reminder on [taskId] with [reminders], in order. */
|
/** Make [taskId]'s reminders exactly [reminders]. A synced task is owed a PUT for it. */
|
||||||
fun setReminders(taskId: Long, reminders: List<TaskReminder>)
|
fun setReminders(taskId: Long, reminders: List<TaskReminder>)
|
||||||
|
|
||||||
/** Every task's reminders, by task id, in stored order. One query, for the scheduler. */
|
/**
|
||||||
fun reminders(): Map<Long, List<TaskReminder>> = alarms().mapValues { listOf(it.value) }
|
* Every task's reminders, by task id, in stored order. One query, for the
|
||||||
|
* scheduler. A task missing from the map takes the default; one present
|
||||||
/** Every task's [editable] reminder, by task id. */
|
* with an empty list has none (see `TaskEntity.remindersOff`).
|
||||||
fun alarms(): Map<Long, TaskReminder>
|
*/
|
||||||
|
fun reminders(): Map<Long, List<TaskReminder>>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every task in [listId] read from the **`tasks` table**, for export. Masters,
|
* Every task in [listId] read from the **`tasks` table**, for export. Masters,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.tasks
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||||
|
import de.jeanlucmakiola.agendula.domain.SavedFilter
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskDetail
|
import de.jeanlucmakiola.agendula.domain.TaskDetail
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||||
@@ -13,6 +15,9 @@ import kotlin.time.Instant
|
|||||||
/** Whether Agendula can use the tasks provider right now. Drives onboarding. */
|
/** Whether Agendula can use the tasks provider right now. Drives onboarding. */
|
||||||
enum class ProviderStatus { READY, NEEDS_PERMISSION, NO_PROVIDER }
|
enum class ProviderStatus { READY, NEEDS_PERMISSION, NO_PROVIDER }
|
||||||
|
|
||||||
|
/** Tasks for a filter; [savedFilter] is a saved filter's definition, `null` once it's deleted. */
|
||||||
|
data class FilteredTasks(val tasks: List<Task>, val savedFilter: SavedFilter? = null)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The single entry point the UI layer uses. Flows re-emit automatically when the
|
* The single entry point the UI layer uses. Flows re-emit automatically when the
|
||||||
* provider changes (our writes *and* external sync like DAVx5), via the
|
* provider changes (our writes *and* external sync like DAVx5), via the
|
||||||
@@ -27,7 +32,10 @@ interface TasksRepository {
|
|||||||
*/
|
*/
|
||||||
fun writableTaskLists(): Flow<List<TaskList>> =
|
fun writableTaskLists(): Flow<List<TaskList>> =
|
||||||
taskLists().map { lists -> lists.filter { it.acceptsWrites } }
|
taskLists().map { lists -> lists.filter { it.acceptsWrites } }
|
||||||
fun tasks(filter: TaskFilter): Flow<List<Task>>
|
fun tasks(filter: TaskFilter): Flow<List<Task>> = filteredTasks(filter).map { it.tasks }
|
||||||
|
|
||||||
|
/** [tasks] together with the definition behind a [TaskFilter.Saved]. */
|
||||||
|
fun filteredTasks(filter: TaskFilter): Flow<FilteredTasks>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The direct children of [parentId], re-emitting live like the other flows.
|
* The direct children of [parentId], re-emitting live like the other flows.
|
||||||
@@ -75,13 +83,6 @@ interface TasksRepository {
|
|||||||
scope: RecurringScope = RecurringScope.AllOccurrences,
|
scope: RecurringScope = RecurringScope.AllOccurrences,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* The per-task reminder lead in minutes before due, or `null` if the task has
|
|
||||||
* none (in which case the list's / global setting applies). Read when the edit
|
|
||||||
* form loads so saving can't silently drop it.
|
|
||||||
*/
|
|
||||||
suspend fun reminderFor(taskId: Long): Int?
|
|
||||||
|
|
||||||
/** Every reminder stored on the task, in order — including start-relative ones. */
|
/** Every reminder stored on the task, in order — including start-relative ones. */
|
||||||
suspend fun remindersFor(taskId: Long): List<TaskReminder>
|
suspend fun remindersFor(taskId: Long): List<TaskReminder>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.tasks
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||||
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||||
|
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.floret.time.DayWindow
|
import de.jeanlucmakiola.floret.time.DayWindow
|
||||||
|
import de.jeanlucmakiola.agendula.domain.SavedFilter
|
||||||
import de.jeanlucmakiola.agendula.domain.SeriesCollapse
|
import de.jeanlucmakiola.agendula.domain.SeriesCollapse
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskDetail
|
import de.jeanlucmakiola.agendula.domain.TaskDetail
|
||||||
@@ -17,7 +20,11 @@ import kotlinx.coroutines.channels.Channel
|
|||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
import kotlinx.coroutines.channels.consumeEach
|
import kotlinx.coroutines.channels.consumeEach
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.callbackFlow
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -32,12 +39,26 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
private val dataSource: TasksDataSource,
|
private val dataSource: TasksDataSource,
|
||||||
private val providerResolver: ProviderResolver,
|
private val providerResolver: ProviderResolver,
|
||||||
private val startupGate: StartupGate,
|
private val startupGate: StartupGate,
|
||||||
|
private val settingsPrefs: SettingsPrefs,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : TasksRepository {
|
) : TasksRepository {
|
||||||
|
|
||||||
override fun taskLists(): Flow<List<TaskList>> = observing { dataSource.taskLists() }
|
override fun taskLists(): Flow<List<TaskList>> = observing { dataSource.taskLists() }
|
||||||
|
|
||||||
override fun tasks(filter: TaskFilter): Flow<List<Task>> = observing { loadTasks(filter) }
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
override fun filteredTasks(filter: TaskFilter): Flow<FilteredTasks> = when (filter) {
|
||||||
|
// A deleted filter shows nothing rather than failing the screen or widget on it.
|
||||||
|
is TaskFilter.Saved -> settingsPrefs.savedFilter(filter.id)
|
||||||
|
.flatMapLatest { saved ->
|
||||||
|
if (saved == null) flowOf(FilteredTasks(emptyList()))
|
||||||
|
else observing {
|
||||||
|
// A deleted list's id would otherwise leave the filter quietly empty.
|
||||||
|
val current = saved.withListsIn(dataSource.taskLists().mapTo(HashSet()) { it.id })
|
||||||
|
FilteredTasks(loadTasks(filter, current), current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> observing { FilteredTasks(loadTasks(filter)) }
|
||||||
|
}
|
||||||
|
|
||||||
override fun subtasks(parentId: Long): Flow<List<Task>> = observing {
|
override fun subtasks(parentId: Long): Flow<List<Task>> = observing {
|
||||||
SeriesCollapse.visible(dataSource.subtasks(parentId))
|
SeriesCollapse.visible(dataSource.subtasks(parentId))
|
||||||
@@ -55,7 +76,7 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadTasks(filter: TaskFilter): List<Task> {
|
private fun loadTasks(filter: TaskFilter, saved: SavedFilter? = null): List<Task> {
|
||||||
val query = when (filter) {
|
val query = when (filter) {
|
||||||
is TaskFilter.OfList -> TaskQuery(listId = filter.listId, includeCompleted = true)
|
is TaskFilter.OfList -> TaskQuery(listId = filter.listId, includeCompleted = true)
|
||||||
// Read completed tasks too, then let [TaskFiltering.matches] enforce each
|
// Read completed tasks too, then let [TaskFiltering.matches] enforce each
|
||||||
@@ -63,6 +84,11 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
// parent's subtask-progress reflects ALL its children — completed ones
|
// parent's subtask-progress reflects ALL its children — completed ones
|
||||||
// included — even on smart lists that hide completed tasks themselves.
|
// included — even on smart lists that hide completed tasks themselves.
|
||||||
is TaskFilter.Smart -> TaskQuery(includeCompleted = true)
|
is TaskFilter.Smart -> TaskQuery(includeCompleted = true)
|
||||||
|
// Same over-read as Smart: TaskQuery.listId only narrows to one list, so
|
||||||
|
// several lists are read in full and membership is filtered in-memory.
|
||||||
|
is TaskFilter.OfLists -> TaskQuery(includeCompleted = true)
|
||||||
|
is TaskFilter.Tagged -> TaskQuery(includeCompleted = true)
|
||||||
|
is TaskFilter.Saved -> TaskQuery(includeCompleted = true)
|
||||||
}
|
}
|
||||||
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
|
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
|
||||||
val all = SeriesCollapse.visible(dataSource.tasks(query))
|
val all = SeriesCollapse.visible(dataSource.tasks(query))
|
||||||
@@ -74,7 +100,7 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
(if (t.isCompleted) done + 1 else done) to (total + 1)
|
(if (t.isCompleted) done + 1 else done) to (total + 1)
|
||||||
}
|
}
|
||||||
return all
|
return all
|
||||||
.filter { TaskFiltering.matches(it, filter, todayStart, todayEnd) }
|
.filter { TaskFiltering.matches(it, filter, todayStart, todayEnd, saved) }
|
||||||
.map { task ->
|
.map { task ->
|
||||||
progress[task.taskId]?.let { (done, total) ->
|
progress[task.taskId]?.let { (done, total) ->
|
||||||
task.copy(subtaskDone = done, subtaskTotal = total)
|
task.copy(subtaskDone = done, subtaskTotal = total)
|
||||||
@@ -85,11 +111,18 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
|
|
||||||
override suspend fun createTask(form: TaskForm): Long =
|
override suspend fun createTask(form: TaskForm): Long =
|
||||||
withContext(io) {
|
withContext(io) {
|
||||||
val id = dataSource.insertTask(form)
|
dataSource.insertTask(form, storedRemindersFor(form, current = null))
|
||||||
writeReminders(id, form)
|
|
||||||
id
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun storedRemindersFor(form: TaskForm, current: Task?): List<TaskReminder> =
|
||||||
|
StoredReminders.of(
|
||||||
|
form,
|
||||||
|
current,
|
||||||
|
settingsPrefs.settings.first(),
|
||||||
|
Clock.System.now().toEpochMilliseconds(),
|
||||||
|
ZoneId.systemDefault(),
|
||||||
|
)
|
||||||
|
|
||||||
override suspend fun duplicateTask(taskId: Long, occurrenceStart: Instant?): Long =
|
override suspend fun duplicateTask(taskId: Long, occurrenceStart: Instant?): Long =
|
||||||
withContext(io) {
|
withContext(io) {
|
||||||
val task = checkNotNull(dataSource.task(taskId, occurrenceStart)) { "Task $taskId is gone" }
|
val task = checkNotNull(dataSource.task(taskId, occurrenceStart)) { "Task $taskId is gone" }
|
||||||
@@ -97,6 +130,9 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun copyOf(task: Task, parentId: Long?, depth: Int): Long {
|
private fun copyOf(task: Task, parentId: Long?, depth: Int): Long {
|
||||||
|
// In the same write as the copy: inserted first and given its reminders
|
||||||
|
// after, a sync landing in between uploads the copy without them.
|
||||||
|
val reminders = dataSource.reminders()[task.taskId].orEmpty().map { it.lead }
|
||||||
val id = dataSource.insertTask(
|
val id = dataSource.insertTask(
|
||||||
TaskForm(
|
TaskForm(
|
||||||
title = task.title,
|
title = task.title,
|
||||||
@@ -109,10 +145,10 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
parentId = parentId,
|
parentId = parentId,
|
||||||
location = task.location,
|
location = task.location,
|
||||||
url = task.url,
|
url = task.url,
|
||||||
|
tags = task.tags,
|
||||||
),
|
),
|
||||||
|
reminders,
|
||||||
)
|
)
|
||||||
val reminders = dataSource.reminders()[task.taskId].orEmpty()
|
|
||||||
if (reminders.isNotEmpty()) dataSource.setReminders(id, reminders)
|
|
||||||
if (depth < MAX_COPY_DEPTH) {
|
if (depth < MAX_COPY_DEPTH) {
|
||||||
SeriesCollapse.visible(dataSource.subtasks(task.taskId))
|
SeriesCollapse.visible(dataSource.subtasks(task.taskId))
|
||||||
.filter { it.taskId != task.taskId && it.status != TaskStatus.CANCELLED }
|
.filter { it.taskId != task.taskId && it.status != TaskStatus.CANCELLED }
|
||||||
@@ -121,9 +157,6 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun reminderFor(taskId: Long): Int? =
|
|
||||||
withContext(io) { runCatching { dataSource.alarms()[taskId]?.minutesBefore }.getOrNull() }
|
|
||||||
|
|
||||||
override suspend fun remindersFor(taskId: Long): List<TaskReminder> =
|
override suspend fun remindersFor(taskId: Long): List<TaskReminder> =
|
||||||
withContext(io) { runCatching { dataSource.reminders()[taskId].orEmpty() }.getOrDefault(emptyList()) }
|
withContext(io) { runCatching { dataSource.reminders()[taskId].orEmpty() }.getOrDefault(emptyList()) }
|
||||||
|
|
||||||
@@ -147,18 +180,17 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
val seen = current?.lastModified
|
val seen = current?.lastModified
|
||||||
if (seen != null && seen != expectedLastModified) throw TaskConflictException(taskId)
|
if (seen != null && seen != expectedLastModified) throw TaskConflictException(taskId)
|
||||||
}
|
}
|
||||||
|
val reminders = storedRemindersFor(form, current)
|
||||||
val seriesId = current?.seriesId
|
val seriesId = current?.seriesId
|
||||||
val occurrence = current?.occurrenceStart
|
val occurrence = current?.occurrenceStart
|
||||||
if (seriesId != null && occurrence != null) {
|
if (seriesId != null && occurrence != null) {
|
||||||
when (scope) {
|
when (scope) {
|
||||||
RecurringScope.AllOccurrences -> {
|
RecurringScope.AllOccurrences -> {
|
||||||
writeReminders(seriesId, form)
|
dataSource.updateSeries(seriesId, occurrence, form, reminders)
|
||||||
dataSource.updateSeries(seriesId, occurrence, form)
|
|
||||||
return@withContext
|
return@withContext
|
||||||
}
|
}
|
||||||
RecurringScope.ThisAndFollowing -> {
|
RecurringScope.ThisAndFollowing -> {
|
||||||
val created = dataSource.splitSeries(seriesId, occurrence, form)
|
dataSource.splitSeries(seriesId, occurrence, form, reminders)
|
||||||
writeReminders(created, form)
|
|
||||||
return@withContext
|
return@withContext
|
||||||
}
|
}
|
||||||
RecurringScope.ThisOccurrence -> Unit
|
RecurringScope.ThisOccurrence -> Unit
|
||||||
@@ -166,31 +198,11 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
// A recurring task's start/due are one occurrence's resolved times, so
|
// A recurring task's start/due are one occurrence's resolved times, so
|
||||||
// writing them back to the task row would re-anchor the whole series.
|
// writing them back to the task row would re-anchor the whole series.
|
||||||
// Going through the occurrence forks an override instead.
|
// Going through the occurrence forks an override instead, which takes
|
||||||
|
// the reminders on its own row and leaves the series' alone.
|
||||||
val instance = current?.takeIf { it.isRecurring }?.occurrenceStart
|
val instance = current?.takeIf { it.isRecurring }?.occurrenceStart
|
||||||
if (instance == null) {
|
if (instance == null) dataSource.updateTask(taskId, form, reminders)
|
||||||
writeReminders(taskId, form)
|
else dataSource.updateInstance(taskId, instance, form, reminders)
|
||||||
dataSource.updateTask(taskId, form)
|
|
||||||
return@withContext
|
|
||||||
}
|
|
||||||
// ⚠️ Written to the series, and then put back. Forking an occurrence
|
|
||||||
// copies the task's *current* properties onto the new override row,
|
|
||||||
// so setting the alarm beforehand is the only way to carry it
|
|
||||||
// across — and leaving it there would silently give every other
|
|
||||||
// occurrence in the series the reminder the user set on one of them.
|
|
||||||
val seriesReminders = dataSource.reminders()[taskId].orEmpty()
|
|
||||||
writeReminders(taskId, form)
|
|
||||||
try {
|
|
||||||
dataSource.updateInstance(taskId, instance, form)
|
|
||||||
} finally {
|
|
||||||
dataSource.setReminders(taskId, seriesReminders)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The form's before-due reminders; a start-relative one from elsewhere is kept as it was. */
|
|
||||||
private fun writeReminders(taskId: Long, form: TaskForm) {
|
|
||||||
val kept = dataSource.reminders()[taskId].orEmpty().filter { it.fromStart }
|
|
||||||
dataSource.setReminders(taskId, kept + form.reminders.distinct().sorted().map { TaskReminder(it) })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun setCancelled(taskId: Long, occurrenceStart: Instant?, cancelled: Boolean) =
|
override suspend fun setCancelled(taskId: Long, occurrenceStart: Instant?, cancelled: Boolean) =
|
||||||
@@ -223,7 +235,7 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
val current = dataSource.task(taskId, occurrenceStart)
|
val current = dataSource.task(taskId, occurrenceStart)
|
||||||
val seriesId = current?.seriesId
|
val seriesId = current?.seriesId
|
||||||
val occurrence = current?.occurrenceStart
|
val occurrence = current?.occurrenceStart
|
||||||
if (seriesId == null || occurrence == null) return@withContext deleteWithSubtasks(taskId)
|
if (seriesId == null || occurrence == null) return@withContext dataSource.deleteWithSubtasks(taskId)
|
||||||
when (scope) {
|
when (scope) {
|
||||||
RecurringScope.ThisOccurrence ->
|
RecurringScope.ThisOccurrence ->
|
||||||
// An override row deletes itself into an EXDATE; a generated
|
// An override row deletes itself into an EXDATE; a generated
|
||||||
@@ -231,18 +243,10 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
if (current.taskId != seriesId) dataSource.deleteTask(current.taskId)
|
if (current.taskId != seriesId) dataSource.deleteTask(current.taskId)
|
||||||
else dataSource.deleteOccurrence(seriesId, occurrence)
|
else dataSource.deleteOccurrence(seriesId, occurrence)
|
||||||
RecurringScope.ThisAndFollowing -> dataSource.deleteFollowing(seriesId, occurrence)
|
RecurringScope.ThisAndFollowing -> dataSource.deleteFollowing(seriesId, occurrence)
|
||||||
RecurringScope.AllOccurrences -> deleteWithSubtasks(seriesId)
|
RecurringScope.AllOccurrences -> dataSource.deleteWithSubtasks(seriesId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A parent goes with its subtasks; left behind they would hang off a tombstone. */
|
|
||||||
private fun deleteWithSubtasks(taskId: Long) {
|
|
||||||
dataSource.subtasks(taskId).map { it.taskId }.distinct()
|
|
||||||
.filter { it != taskId }
|
|
||||||
.forEach { dataSource.deleteTask(it) }
|
|
||||||
dataSource.deleteTask(taskId)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun createLocalList(name: String, color: Int): Long =
|
override suspend fun createLocalList(name: String, color: Int): Long =
|
||||||
withContext(io) { dataSource.createLocalList(name, color) }
|
withContext(io) { dataSource.createLocalList(name, color) }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks.ical
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.agendula.data.reminders.ReminderPlanner
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
||||||
|
import de.jeanlucmakiola.agendula.domain.ical.ICalComponent
|
||||||
|
import de.jeanlucmakiola.agendula.domain.ical.ICalParam
|
||||||
|
import de.jeanlucmakiola.agendula.domain.ical.ICalParser
|
||||||
|
import de.jeanlucmakiola.agendula.domain.ical.ICalProperty
|
||||||
|
import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer
|
||||||
|
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
|
||||||
|
import de.jeanlucmakiola.agendula.domain.DAY_MINUTES
|
||||||
|
import de.jeanlucmakiola.agendula.domain.MIN_ALL_DAY_LEAD
|
||||||
|
import kotlin.math.roundToLong
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `VALARM` ↔ [TaskAlarmEntity].
|
||||||
|
*
|
||||||
|
* ## What is claimed
|
||||||
|
*
|
||||||
|
* Only an alarm Agendula can fire exactly as written becomes a reminder row.
|
||||||
|
* Everything else stays in the task's residue and round-trips untouched — it
|
||||||
|
* still fires in the client that wrote it, just not here:
|
||||||
|
*
|
||||||
|
* - `ACTION` other than `DISPLAY`/`AUDIO`;
|
||||||
|
* - Apple's default alarms (`X-APPLE-DEFAULT-ALARM:TRUE`). They are that Mac's
|
||||||
|
* preference, not task data: claimed, they would come back to the Mac as an
|
||||||
|
* explicit alarm without the flag after the first edit here;
|
||||||
|
* - location alarms (`X-APPLE-PROXIMITY`, RFC 9074 `PROXIMITY`, a nested
|
||||||
|
* component) and Apple's `19760401T005545Z` sentinel trigger they carry;
|
||||||
|
* - repeating alarms (`REPEAT`/`DURATION`) and RFC 9074 snoozes (`RELATED-TO`);
|
||||||
|
* - a trigger *after* its anchor — on an all-day task, after the end of its
|
||||||
|
* day. The planner coerces a negative lead to 0, so it would fire at the
|
||||||
|
* wrong time;
|
||||||
|
* - an absolute trigger on a recurring task, or on one with no `DUE` to count
|
||||||
|
* back from.
|
||||||
|
*
|
||||||
|
* ## What "unchanged" means
|
||||||
|
*
|
||||||
|
* A claimed row keeps the `VALARM` it was read from in [TaskAlarmEntity.raw].
|
||||||
|
* On write, the row is "unchanged" when that original still [resolve]s to the
|
||||||
|
* row's lead against the task *as it is now* — so `-PT15M` and `-P0DT0H15M0S`
|
||||||
|
* are the same, a relative alarm survives a due-date edit, and an absolute one
|
||||||
|
* stops matching once `DUE` moves. An unchanged row is re-emitted verbatim; a
|
||||||
|
* changed one is authored fresh under its existing uid.
|
||||||
|
*
|
||||||
|
* ## Anchors
|
||||||
|
*
|
||||||
|
* `RELATED=END` counts from `DUE`. `RELATED=START` (the default) counts from
|
||||||
|
* `DTSTART` — except when `DTSTART` equals `DUE`, or when there is no
|
||||||
|
* `DTSTART` but there is a `DUE`. Apple writes the latter shape (against §3.8.6.3,
|
||||||
|
* which wants `DTSTART` present).
|
||||||
|
*
|
||||||
|
* ## All-day tasks
|
||||||
|
*
|
||||||
|
* A relative trigger counts from the date's midnight, so the lead is the
|
||||||
|
* reminder as [de.jeanlucmakiola.agendula.domain.TaskReminder.allDay] reads it:
|
||||||
|
* `-P1D` is a day before at this device's all-day time, which this mapper cannot
|
||||||
|
* see, and `PT9H` is 09:00 on the day. An absolute trigger has no zone to read
|
||||||
|
* a time of day in, so it rounds to the nearest whole day.
|
||||||
|
*/
|
||||||
|
object AlarmCodec {
|
||||||
|
|
||||||
|
/** Apple's placeholder trigger for location alarms. Never a real time. */
|
||||||
|
private const val APPLE_SENTINEL = "19760401T005545Z"
|
||||||
|
|
||||||
|
/** What a `VALARM` means to the scheduler. */
|
||||||
|
data class Lead(val minutesBefore: Int, val reference: AlarmReference)
|
||||||
|
|
||||||
|
/** A `VALARM` as a reminder row — `taskId` is the caller's — or null if it is not ours to claim. */
|
||||||
|
fun fromVAlarm(alarm: ICalComponent, task: TaskEntity): TaskAlarmEntity? {
|
||||||
|
val lead = resolve(alarm, task) ?: return null
|
||||||
|
val raw = ICalSerializer.serialize(alarm)
|
||||||
|
// Too large to keep verbatim means it could not be re-emitted, and an
|
||||||
|
// alarm that is authored fresh on every write loses what made it large.
|
||||||
|
if (raw.toByteArray(Charsets.UTF_8).size > VTodoMapper.MAX_RESIDUE_BYTES) return null
|
||||||
|
val uid = (alarm.property("X-WR-ALARMUID") ?: alarm.property("UID"))?.value?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
val base = TaskAlarmEntity(
|
||||||
|
taskId = 0,
|
||||||
|
minutesBefore = lead.minutesBefore,
|
||||||
|
reference = lead.reference,
|
||||||
|
raw = raw,
|
||||||
|
)
|
||||||
|
return if (uid == null) base else base.copy(uid = uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The lead [alarm] stands for on [task], or null when it is not claimable. */
|
||||||
|
fun resolve(alarm: ICalComponent, task: TaskEntity): Lead? {
|
||||||
|
val action = alarm.property("ACTION")?.value?.trim()?.uppercase()
|
||||||
|
if (action != "DISPLAY" && action != "AUDIO") return null
|
||||||
|
if (alarm.property("X-APPLE-DEFAULT-ALARM")?.value?.trim().equals("TRUE", ignoreCase = true)) return null
|
||||||
|
val excluded = listOf("X-APPLE-PROXIMITY", "PROXIMITY", "REPEAT", "DURATION", "RELATED-TO")
|
||||||
|
if (excluded.any { alarm.property(it) != null }) return null
|
||||||
|
if (alarm.components.isNotEmpty()) return null
|
||||||
|
|
||||||
|
val trigger = alarm.properties("TRIGGER").singleOrNull() ?: return null
|
||||||
|
val value = trigger.value.trim()
|
||||||
|
if (value.startsWith(APPLE_SENTINEL)) return null
|
||||||
|
|
||||||
|
val absolute = trigger.param("VALUE").equals("DATE-TIME", ignoreCase = true) ||
|
||||||
|
(!value.startsWith("P", ignoreCase = true) && !value.startsWith("-") && !value.startsWith("+"))
|
||||||
|
return if (absolute) absoluteLead(trigger, task) else relativeLead(trigger, task)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun relativeLead(trigger: ICalProperty, task: TaskEntity): Lead? {
|
||||||
|
val seconds = ICalValues.parseDuration(trigger.value) ?: return null
|
||||||
|
val reference = when {
|
||||||
|
trigger.param("RELATED").equals("END", ignoreCase = true) ->
|
||||||
|
if (task.due != null) AlarmReference.DUE else return null
|
||||||
|
task.dtstart != null && task.dtstart != task.due -> AlarmReference.START
|
||||||
|
task.due != null -> AlarmReference.DUE
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
val before = -seconds
|
||||||
|
// A lead in seconds is not one the planner can reproduce.
|
||||||
|
if (before % 60 != 0L) return null
|
||||||
|
val minutes = before / 60
|
||||||
|
val latest = if (task.isAllDay) MIN_ALL_DAY_LEAD.toLong() else 0L
|
||||||
|
return minutes.takeIf { it in latest..Int.MAX_VALUE }?.let { Lead(it.toInt(), reference) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun absoluteLead(trigger: ICalProperty, task: TaskEntity): Lead? {
|
||||||
|
if (task.rrule != null || task.rdate != null) return null
|
||||||
|
val due = task.due ?: return null
|
||||||
|
val at = (ICalValues.parseTime(trigger) as? ICalValues.TimeValue.Timed)
|
||||||
|
?.takeIf { it.tzid == null } // §3.8.6.3: an absolute trigger MUST be UTC
|
||||||
|
?.instant ?: return null
|
||||||
|
val before = (due - at).inWholeSeconds
|
||||||
|
val minutes = if (task.isAllDay) {
|
||||||
|
// An all-day DUE is stored as UTC midnight of its date, which is
|
||||||
|
// the calendar date the trigger counts back from.
|
||||||
|
roundToDays(before)
|
||||||
|
} else {
|
||||||
|
Math.floorDiv(before, 60L)
|
||||||
|
}
|
||||||
|
return minutes.toIntOrNull()?.let { Lead(it, AlarmReference.DUE) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [read] as builds before 1.2 stored it on an all-day task: rounded to whole
|
||||||
|
* days. Such a row keeps the other client's time of day instead of overwriting it.
|
||||||
|
*/
|
||||||
|
private fun readAsWholeDays(read: Lead, task: TaskEntity): Lead? =
|
||||||
|
if (task.isAllDay && read.minutesBefore % DAY_MINUTES != 0) {
|
||||||
|
read.copy(minutesBefore = roundToDays(read.minutesBefore * 60L).coerceAtLeast(0).toInt())
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nearest whole day, in minutes. */
|
||||||
|
private fun roundToDays(seconds: Long): Long =
|
||||||
|
(seconds / 86_400.0).roundToLong() * DAY_MINUTES
|
||||||
|
|
||||||
|
/** Negative is after the anchor, which is not claimed. */
|
||||||
|
private fun Long.toIntOrNull(): Int? = takeIf { it in 0..Int.MAX_VALUE }?.toInt()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [row] as a `VALARM` on [task], or null when it cannot be written: its
|
||||||
|
* anchor is missing. That row stays on the device — the planner ignores it
|
||||||
|
* too — because `RELATED=END` without `DUE` is a resource the server
|
||||||
|
* refuses ([VTodoMapper.validate]).
|
||||||
|
*
|
||||||
|
* ⚠️ A row whose anchor the task has lost is written against the other
|
||||||
|
* date, which is what the planner fires it from. Leaving it out would drop
|
||||||
|
* it from every other client, and the next download would then take it off
|
||||||
|
* this device too.
|
||||||
|
*
|
||||||
|
* [now] marks an authored alarm `ACKNOWLEDGED` once it is past even the
|
||||||
|
* planner's missed-reminder grace, so a client that honours RFC 9074 does
|
||||||
|
* not greet it as a missed alarm. Not sooner: the stamp comes back on the
|
||||||
|
* next download and would silence the late reminder this device still owes.
|
||||||
|
*/
|
||||||
|
fun toVAlarm(row: TaskAlarmEntity, task: TaskEntity, now: Instant): ICalComponent? {
|
||||||
|
val reference = when {
|
||||||
|
row.reference == AlarmReference.START && task.dtstart == null -> AlarmReference.DUE
|
||||||
|
row.reference == AlarmReference.DUE && task.due == null -> AlarmReference.START
|
||||||
|
else -> row.reference
|
||||||
|
}
|
||||||
|
val lead = Lead(row.minutesBefore, reference)
|
||||||
|
row.raw?.let(::parse)?.let { original ->
|
||||||
|
val read = resolve(original, task)
|
||||||
|
if (read == lead || read != null && readAsWholeDays(read, task) == lead) return original
|
||||||
|
}
|
||||||
|
|
||||||
|
val anchor = when (reference) {
|
||||||
|
AlarmReference.DUE -> task.due
|
||||||
|
AlarmReference.START -> task.dtstart
|
||||||
|
} ?: return null
|
||||||
|
val related = if (reference == AlarmReference.DUE) "END" else "START"
|
||||||
|
val minutes = row.minutesBefore.coerceAtLeast(if (task.isAllDay) MIN_ALL_DAY_LEAD else 0).toLong()
|
||||||
|
val seconds = -minutes * 60
|
||||||
|
|
||||||
|
val properties = mutableListOf(
|
||||||
|
ICalProperty("UID", value = row.uid),
|
||||||
|
ICalProperty("X-WR-ALARMUID", value = row.uid),
|
||||||
|
ICalProperty("ACTION", value = "DISPLAY"),
|
||||||
|
ICalProperty("DESCRIPTION", value = ICalValues.escapeText(row.message ?: "Reminder")),
|
||||||
|
ICalProperty("TRIGGER", listOf(ICalParam("RELATED", related)), ICalValues.formatDuration(seconds)),
|
||||||
|
)
|
||||||
|
val recurring = task.rrule != null || task.rdate != null
|
||||||
|
if (!recurring && anchor + seconds.seconds + ReminderPlanner.MISSED_GRACE_MS.milliseconds < now) {
|
||||||
|
properties += ICalProperty("ACKNOWLEDGED", value = ICalValues.formatDateTime(now, null))
|
||||||
|
}
|
||||||
|
return ICalComponent("VALARM", properties)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What [row] reads back as once it has been to the server and back — not
|
||||||
|
* always the row itself, because the wire is coarser: a lead past the end
|
||||||
|
* of its anchor is clamped, and a before-start row on a task without a
|
||||||
|
* start goes out before-due. A download that brings back exactly this has not changed the
|
||||||
|
* reminder, and the row's own lead must be kept rather than taken from it.
|
||||||
|
*
|
||||||
|
* ⚠️ As authored from the row, not re-emitted from [TaskAlarmEntity.raw]:
|
||||||
|
* a row rounded to whole days before 1.2 re-emits a time of day it does
|
||||||
|
* not hold, and would keep its rounded lead against the very alarm that
|
||||||
|
* carries it.
|
||||||
|
*/
|
||||||
|
fun wireLead(row: TaskAlarmEntity, task: TaskEntity): Lead? =
|
||||||
|
toVAlarm(row.copy(raw = null), task, Instant.DISTANT_PAST)?.let { resolve(it, task) }
|
||||||
|
|
||||||
|
private fun parse(raw: String): ICalComponent? =
|
||||||
|
runCatching { ICalParser.parse(raw) }.getOrNull()?.takeIf { it.name.equals("VALARM", ignoreCase = true) }
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.tasks.ical
|
package de.jeanlucmakiola.agendula.data.tasks.ical
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
||||||
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
|
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||||
@@ -9,6 +10,7 @@ import de.jeanlucmakiola.agendula.domain.ical.ICalParser
|
|||||||
import de.jeanlucmakiola.agendula.domain.ical.ICalProperty
|
import de.jeanlucmakiola.agendula.domain.ical.ICalProperty
|
||||||
import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer
|
import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer
|
||||||
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
|
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
|
||||||
|
import de.jeanlucmakiola.agendula.domain.normalizeTags
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
@@ -51,10 +53,11 @@ import kotlin.time.Instant
|
|||||||
*
|
*
|
||||||
* ## Alarms
|
* ## Alarms
|
||||||
*
|
*
|
||||||
* `VALARM`s round-trip in the residue and are never authored here. Local
|
* A `VALARM` [AlarmCodec] can fire exactly as written is claimed into
|
||||||
* reminders live in `task_alarms` and are never serialised. The two stores are
|
* [Mapped.alarms] — rows for `task_alarms`, carrying the original verbatim —
|
||||||
* disjoint, so neither can destroy the other. Merging them is a later
|
* and [write] authors the task's rows back. Any other `VALARM` (Apple's default
|
||||||
* decision, not a silent one.
|
* and location alarms, repeating ones, snoozes) stays in the residue and is
|
||||||
|
* re-emitted untouched after the authored ones.
|
||||||
*/
|
*/
|
||||||
object VTodoMapper {
|
object VTodoMapper {
|
||||||
|
|
||||||
@@ -81,6 +84,8 @@ object VTodoMapper {
|
|||||||
// Parameterised copies stay in the residue verbatim; authoring the
|
// Parameterised copies stay in the residue verbatim; authoring the
|
||||||
// column beside them would emit the same exclusions twice.
|
// column beside them would emit the same exclusions twice.
|
||||||
"RDATE", "EXDATE",
|
"RDATE", "EXDATE",
|
||||||
|
// Several lines, or one the column would not re-serialise exactly.
|
||||||
|
"CATEGORIES",
|
||||||
)
|
)
|
||||||
|
|
||||||
/** What a VTODO yields. Row identity ([TaskEntity.id], `listId`) is the caller's. */
|
/** What a VTODO yields. Row identity ([TaskEntity.id], `listId`) is the caller's. */
|
||||||
@@ -92,6 +97,8 @@ object VTodoMapper {
|
|||||||
val uidWasMissing: Boolean,
|
val uidWasMissing: Boolean,
|
||||||
/** Residue that exceeded [MAX_RESIDUE_BYTES] and had to be dropped. */
|
/** Residue that exceeded [MAX_RESIDUE_BYTES] and had to be dropped. */
|
||||||
val droppedResidue: Boolean,
|
val droppedResidue: Boolean,
|
||||||
|
/** The claimed `VALARM`s, `taskId` unset. Not counted against the residue cap. */
|
||||||
|
val alarms: List<TaskAlarmEntity> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---------------------------------------------------------------- read
|
// ---------------------------------------------------------------- read
|
||||||
@@ -200,6 +207,9 @@ object VTodoMapper {
|
|||||||
CLASS_NAMES.indexOf(classProperty?.value?.trim()?.uppercase()).takeIf { it >= 0 },
|
CLASS_NAMES.indexOf(classProperty?.value?.trim()?.uppercase()).takeIf { it >= 0 },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val (categories, categoriesProperty) = readCategories(vtodo)
|
||||||
|
claim(categoriesProperty)
|
||||||
|
|
||||||
val sequenceProperty = vtodo.property("SEQUENCE")
|
val sequenceProperty = vtodo.property("SEQUENCE")
|
||||||
val sequence = take(
|
val sequence = take(
|
||||||
sequenceProperty,
|
sequenceProperty,
|
||||||
@@ -225,6 +235,7 @@ object VTodoMapper {
|
|||||||
title = take(vtodo.property("SUMMARY"), vtodo.property("SUMMARY")?.text()),
|
title = take(vtodo.property("SUMMARY"), vtodo.property("SUMMARY")?.text()),
|
||||||
description = take(vtodo.property("DESCRIPTION"), vtodo.property("DESCRIPTION")?.text()),
|
description = take(vtodo.property("DESCRIPTION"), vtodo.property("DESCRIPTION")?.text()),
|
||||||
location = take(vtodo.property("LOCATION"), vtodo.property("LOCATION")?.text()),
|
location = take(vtodo.property("LOCATION"), vtodo.property("LOCATION")?.text()),
|
||||||
|
categories = categories,
|
||||||
// URI, not TEXT — escaping it would corrupt a query string.
|
// URI, not TEXT — escaping it would corrupt a query string.
|
||||||
url = take(vtodo.property("URL"), vtodo.property("URL")?.value?.trim()),
|
url = take(vtodo.property("URL"), vtodo.property("URL")?.value?.trim()),
|
||||||
status = status,
|
status = status,
|
||||||
@@ -256,8 +267,18 @@ object VTodoMapper {
|
|||||||
.filterIndexed { index, _ -> index !in claimed }
|
.filterIndexed { index, _ -> index !in claimed }
|
||||||
.filterNot { it.name.equals("DTSTAMP", ignoreCase = true) }
|
.filterNot { it.name.equals("DTSTAMP", ignoreCase = true) }
|
||||||
|
|
||||||
|
// Claimed against the entity as read, so the anchors are the ones the
|
||||||
|
// rows will be written back against.
|
||||||
|
val alarms = mutableListOf<TaskAlarmEntity>()
|
||||||
|
val residueComponents = vtodo.components.filter { component ->
|
||||||
|
if (!component.name.equals("VALARM", ignoreCase = true)) return@filter true
|
||||||
|
val row = AlarmCodec.fromVAlarm(component, entity) ?: return@filter true
|
||||||
|
alarms += row
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
val residueText = ICalSerializer.serializeProperties(residueProperties) +
|
val residueText = ICalSerializer.serializeProperties(residueProperties) +
|
||||||
ICalSerializer.serializeAll(vtodo.components)
|
ICalSerializer.serializeAll(residueComponents)
|
||||||
val tooLarge = residueText.toByteArray(Charsets.UTF_8).size > MAX_RESIDUE_BYTES
|
val tooLarge = residueText.toByteArray(Charsets.UTF_8).size > MAX_RESIDUE_BYTES
|
||||||
|
|
||||||
return Mapped(
|
return Mapped(
|
||||||
@@ -267,6 +288,7 @@ object VTodoMapper {
|
|||||||
parentUid = parentUid,
|
parentUid = parentUid,
|
||||||
uidWasMissing = uid.isEmpty(),
|
uidWasMissing = uid.isEmpty(),
|
||||||
droppedResidue = tooLarge,
|
droppedResidue = tooLarge,
|
||||||
|
alarms = alarms,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,16 +298,19 @@ object VTodoMapper {
|
|||||||
* Serialises [entity] back to a VTODO.
|
* Serialises [entity] back to a VTODO.
|
||||||
*
|
*
|
||||||
* [parentUid] is the parent's `UID`, which the entity holds only as a row id.
|
* [parentUid] is the parent's `UID`, which the entity holds only as a row id.
|
||||||
* [now] is the `DTSTAMP`.
|
* [now] is the `DTSTAMP`. [alarms] are the task's reminder rows; one whose
|
||||||
|
* anchor the task no longer has is left out (see [AlarmCodec.toVAlarm]).
|
||||||
*/
|
*/
|
||||||
fun write(
|
fun write(
|
||||||
entity: TaskEntity,
|
entity: TaskEntity,
|
||||||
parentUid: String? = null,
|
parentUid: String? = null,
|
||||||
now: Instant = Clock.System.now(),
|
now: Instant = Clock.System.now(),
|
||||||
|
alarms: List<TaskAlarmEntity> = emptyList(),
|
||||||
): ICalComponent {
|
): ICalComponent {
|
||||||
val residue = parseResidue(entity.unknownProperties)
|
val residue = parseResidue(entity.unknownProperties)
|
||||||
|
val residueCategories = normalizeTags(residue.properties("CATEGORIES").flatMap(::categoryValues))
|
||||||
val keptResidue =
|
val keptResidue =
|
||||||
residue.properties.filterNot { contradictsResidue(it, residue, entity, parentUid) }
|
residue.properties.filterNot { contradictsResidue(it, residue, residueCategories, entity, parentUid) }
|
||||||
val suppressed = keptResidue
|
val suppressed = keptResidue
|
||||||
.map { it.name.uppercase() }
|
.map { it.name.uppercase() }
|
||||||
.filterTo(mutableSetOf()) { it in SUPPRESSED_BY_RESIDUE }
|
.filterTo(mutableSetOf()) { it in SUPPRESSED_BY_RESIDUE }
|
||||||
@@ -307,6 +332,7 @@ object VTodoMapper {
|
|||||||
add("DESCRIPTION", entity.description?.let(ICalValues::escapeText))
|
add("DESCRIPTION", entity.description?.let(ICalValues::escapeText))
|
||||||
add("LOCATION", entity.location?.let(ICalValues::escapeText))
|
add("LOCATION", entity.location?.let(ICalValues::escapeText))
|
||||||
add("URL", entity.url)
|
add("URL", entity.url)
|
||||||
|
add("CATEGORIES", normalizeTags(entity.categories).takeIf { it.isNotEmpty() }?.let(ICalValues::formatTextList))
|
||||||
|
|
||||||
add("STATUS", entity.status.toICalName())
|
add("STATUS", entity.status.toICalName())
|
||||||
add("PERCENT-COMPLETE", entity.percentComplete?.toString())
|
add("PERCENT-COMPLETE", entity.percentComplete?.toString())
|
||||||
@@ -340,7 +366,7 @@ object VTodoMapper {
|
|||||||
return ICalComponent(
|
return ICalComponent(
|
||||||
name = "VTODO",
|
name = "VTODO",
|
||||||
properties = properties + keptResidue,
|
properties = properties + keptResidue,
|
||||||
components = residue.components,
|
components = alarms.mapNotNull { AlarmCodec.toVAlarm(it, entity, now) } + residue.components,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,6 +412,7 @@ object VTodoMapper {
|
|||||||
private fun contradictsResidue(
|
private fun contradictsResidue(
|
||||||
property: ICalProperty,
|
property: ICalProperty,
|
||||||
residue: ICalComponent,
|
residue: ICalComponent,
|
||||||
|
residueCategories: List<String>,
|
||||||
entity: TaskEntity,
|
entity: TaskEntity,
|
||||||
parentUid: String?,
|
parentUid: String?,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
@@ -433,6 +460,7 @@ object VTodoMapper {
|
|||||||
"PRIORITY" -> entity.priority != PRIORITY_NONE
|
"PRIORITY" -> entity.priority != PRIORITY_NONE
|
||||||
"PERCENT-COMPLETE" -> entity.percentComplete != null
|
"PERCENT-COMPLETE" -> entity.percentComplete != null
|
||||||
"CLASS" -> entity.classification != null
|
"CLASS" -> entity.classification != null
|
||||||
|
"CATEGORIES" -> entity.categories != residueCategories
|
||||||
// Not SEQUENCE: it is the organiser's counter and never ours to bump,
|
// Not SEQUENCE: it is the organiser's counter and never ours to bump,
|
||||||
// so the column stays at its fallback and the residue always wins.
|
// so the column stays at its fallback and the residue always wins.
|
||||||
else -> false
|
else -> false
|
||||||
@@ -475,6 +503,23 @@ object VTodoMapper {
|
|||||||
|
|
||||||
// -------------------------------------------------------------- helpers
|
// -------------------------------------------------------------- helpers
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every `CATEGORIES` copy's values, merged, and the one line to claim — only
|
||||||
|
* when it is the sole, parameterless copy and the column re-serialises it
|
||||||
|
* byte for byte. Anything else is read for display and stays in the residue.
|
||||||
|
*/
|
||||||
|
private fun readCategories(vtodo: ICalComponent): Pair<List<String>, ICalProperty?> {
|
||||||
|
val copies = vtodo.properties("CATEGORIES")
|
||||||
|
val values = normalizeTags(copies.flatMap(::categoryValues))
|
||||||
|
val claimable = copies.singleOrNull()?.takeIf {
|
||||||
|
it.params.isEmpty() && values.isNotEmpty() && ICalValues.formatTextList(values) == it.value
|
||||||
|
}
|
||||||
|
return values to claimable
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun categoryValues(property: ICalProperty): List<String> =
|
||||||
|
normalizeTags(ICalValues.parseTextList(property.value))
|
||||||
|
|
||||||
private val CLASS_NAMES = listOf("PUBLIC", "PRIVATE", "CONFIDENTIAL")
|
private val CLASS_NAMES = listOf("PUBLIC", "PRIVATE", "CONFIDENTIAL")
|
||||||
|
|
||||||
private class TimeRead(
|
private class TimeRead(
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user