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 |
No files matched your search
@@ -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()
|
||||||
|
|||||||
File renamed without changes.
File renamed without changes.
@@ -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
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+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
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+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"
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+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)
|
||||||
}
|
}
|
||||||
File renamed without changes.
+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.
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+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))
|
||||||
|
|
||||||
File renamed without changes.
+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
|
||||||
|
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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(
|
||||||
|
|||||||
Loaded 100 of 282 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user