Compare commits
47 Commits
5f4711dabe
...
feat/own-s
| Author | SHA1 | Date | |
|---|---|---|---|
| fdbc236ab4 | |||
| ec2e2eb59d | |||
| 9ff6027e50 | |||
| fcee1d1736 | |||
| faee90f8b1 | |||
| 90140112bb | |||
| 1d4fe5b301 | |||
| 5abcbfc956 | |||
| 1ed192f150 | |||
| 76f9ae6780 | |||
| 2e915da588 | |||
| 829a27da82 | |||
| fd8363e356 | |||
| 96a2995df4 | |||
| 11b20faf82 | |||
| 8c3cbcf928 | |||
| 13cb27b2ab | |||
| 98ed339346 | |||
| c5041d3f29 | |||
| f978c3727c | |||
| 3d54a896ce | |||
|
|
c47653c9cb | ||
| e639e250b7 | |||
|
|
58a50512bf | ||
|
|
1add1fcadb | ||
|
|
217d5d7afd | ||
| 47cf99af32 | |||
| 26628dc0bb | |||
| d2e3832ef2 | |||
| 93857135b3 | |||
| 36beb2d0ad | |||
| bc70ed3a9f | |||
| 3f166ef5f0 | |||
| 41bd49826a | |||
| 05c75bafa7 | |||
| cfa25b9730 | |||
| 9d7fc64b0b | |||
| 4aa65edb45 | |||
| a9843e25b7 | |||
| 976d496d21 | |||
| 245f1db536 | |||
| 623e533547 | |||
| 2e50356f81 | |||
| e6f503c02a | |||
| c53511196d | |||
| a8595e26b4 | |||
| 411e27659f |
24
.forgejo/ISSUE_TEMPLATE/config.yml
Normal file
24
.forgejo/ISSUE_TEMPLATE/config.yml
Normal file
@@ -0,0 +1,24 @@
|
||||
# Kept enabled so anything that doesn't fit the four templates still has a way
|
||||
# in.
|
||||
blank_issues_enabled: true
|
||||
|
||||
contact_links:
|
||||
- name: Translate Agendula
|
||||
url: https://weblate.dev.jeanlucmakiola.de/engage/agendula/
|
||||
about: >-
|
||||
Translations are managed on Weblate, not here — it owns every values-*
|
||||
file, so a hand-edited translation gets overwritten on the next sync.
|
||||
No coding needed: pick or request a language and translate in the browser.
|
||||
|
||||
- name: Contributing guide
|
||||
url: https://codeberg.org/jlmakiola/agendula/src/branch/main/CONTRIBUTING.md
|
||||
about: >-
|
||||
Before opening a pull request: how to build (there's a submodule), where
|
||||
code goes, and the one architectural rule a change is reviewed against.
|
||||
|
||||
- name: Sync sources and scope
|
||||
url: https://codeberg.org/jlmakiola/agendula/src/branch/main/README.md
|
||||
about: >-
|
||||
Agendula is a front-end over the OpenTasks provider, so it works with
|
||||
DAVx5, SmoothSync, DecSync and friends. Google Tasks and Microsoft To Do
|
||||
are out of scope by design — check here before requesting a backend.
|
||||
42
.forgejo/PULL_REQUEST_TEMPLATE.md
Normal file
42
.forgejo/PULL_REQUEST_TEMPLATE.md
Normal file
@@ -0,0 +1,42 @@
|
||||
<!--
|
||||
Thanks for contributing to Agendula!
|
||||
|
||||
Please skim CONTRIBUTING.md if you haven't:
|
||||
https://codeberg.org/jlmakiola/agendula/src/branch/main/CONTRIBUTING.md
|
||||
|
||||
Two things it's easy to get wrong:
|
||||
• The one architectural rule — provider column names, `TaskContract`,
|
||||
`ContentResolver` and the authority string never leak above `data/tasks/`.
|
||||
• Don't bump `versionName` / `versionCode`. That bump reaching `main` is what
|
||||
cuts a release, so it belongs only in a release PR.
|
||||
-->
|
||||
|
||||
### What this changes
|
||||
|
||||
|
||||
### Why
|
||||
|
||||
<!-- Closes #123 — link the issue this implements or fixes. -->
|
||||
|
||||
|
||||
### How it was tested
|
||||
|
||||
<!--
|
||||
Which of these ran green, and anything you exercised by hand. On-device notes
|
||||
are especially useful for UI changes, and for anything touching the provider
|
||||
read/write paths (OpenTasks / tasks.org installed).
|
||||
|
||||
./gradlew lintDebug :app:testDebugUnitTest :app:assembleDebug
|
||||
python3 scripts/check_translations.py
|
||||
-->
|
||||
|
||||
|
||||
### Checklist
|
||||
|
||||
- [ ] `./gradlew lintDebug :app:testDebugUnitTest :app:assembleDebug` passes locally
|
||||
- [ ] New domain logic comes with JVM unit tests under `app/src/test/`
|
||||
- [ ] Provider details stay inside `data/tasks/`
|
||||
- [ ] No `values-*/strings.xml` touched (Weblate owns those; new English strings in `values/` are fine)
|
||||
- [ ] `CHANGELOG.md` updated under `## [Unreleased]`, if the change is user-visible
|
||||
- [ ] No `versionName` / `versionCode` bump
|
||||
- [ ] No planning or design documents committed
|
||||
@@ -37,14 +37,29 @@ jobs:
|
||||
- name: Reproducible-release invariant
|
||||
run: bash scripts/check_reproducible_release.sh
|
||||
|
||||
# Decide whether anything that affects the app build changed. Docs,
|
||||
# F-Droid metadata and the licence don't, so those PRs skip the SDK +
|
||||
# Gradle work below but still report a green `ci`.
|
||||
# Decide whether anything that affects the app build changed. Docs, store
|
||||
# metadata, licence texts and forge housekeeping don't, so those PRs skip
|
||||
# the SDK + Gradle work below but still report a green `ci`.
|
||||
- name: Classify change scope
|
||||
id: scope
|
||||
env:
|
||||
# Deliberately a skip-list, not a build-list: a path nobody thought
|
||||
# about defaults to building. Only paths the Gradle build provably
|
||||
# never reads belong here — note that the workflows themselves, the
|
||||
# `.gitmodules` submodule pointer and `scripts/` are *not* in it.
|
||||
SKIP_RE: '(\.md$|^docs/|^fastlane/|^fdroid-metadata/|^design/|^\.(forgejo|gitea)/ISSUE_TEMPLATE/|^\.editorconfig$|^\.gitattributes$|^\.gitignore$|^LICENSE$)'
|
||||
run: |
|
||||
set -e
|
||||
BASE="${{ github.base_ref }}"
|
||||
# Normally the bare branch name; tolerate a full ref, which would
|
||||
# otherwise make the merge-base lookup fail and quietly degrade this
|
||||
# guard into "always build".
|
||||
BASE="${BASE#refs/heads/}"
|
||||
if [ -z "$BASE" ]; then
|
||||
echo "No base branch on this event — running the full build to be safe."
|
||||
echo "code=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# Full (not --depth=1) base fetch so the merge-base is present even when
|
||||
# the PR branch forked several commits back; a shallow tip has no merge
|
||||
# base with a divergent branch and `git diff base...HEAD` aborts.
|
||||
@@ -58,11 +73,15 @@ jobs:
|
||||
fi
|
||||
CHANGED=$(git diff --name-only "$MB" HEAD)
|
||||
echo "Changed files:"; echo "$CHANGED"
|
||||
if echo "$CHANGED" | grep -vE '(\.md$|^docs/|^fdroid-metadata/|^fastlane/|^LICENSE$)' | grep -q .; then
|
||||
RELEVANT=$(echo "$CHANGED" | grep -vE "$SKIP_RE" || true)
|
||||
if [ -n "$RELEVANT" ]; then
|
||||
# Naming them makes "why did my docs PR build for four minutes?"
|
||||
# answerable from the log alone.
|
||||
echo "Build-relevant changes:"; echo "$RELEVANT"
|
||||
echo "code=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "code=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Docs/metadata-only change — skipping the Android build."
|
||||
echo "code=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Setup Java
|
||||
@@ -72,9 +91,14 @@ jobs:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
|
||||
# Fully qualified on purpose. Codeberg resolves bare `uses:` refs against
|
||||
# data.forgejo.org, Forgejo's own action mirror — actions/checkout,
|
||||
# setup-java and cache all exist there, but android-actions/setup-android
|
||||
# does not, and the job dies with "repository not found". Gitea's instance
|
||||
# defaults to GitHub, which is why this never surfaced before the split.
|
||||
- name: Setup Android SDK
|
||||
if: steps.scope.outputs.code == 'true'
|
||||
uses: android-actions/setup-android@v3
|
||||
uses: https://github.com/android-actions/setup-android@v3
|
||||
with:
|
||||
# Default ("tools platform-tools") drags in the Android Emulator
|
||||
# (~300 MB) which the build never uses.
|
||||
39
.forgejo/workflows/translations.yaml
Normal file
39
.forgejo/workflows/translations.yaml
Normal file
@@ -0,0 +1,39 @@
|
||||
name: Translations
|
||||
|
||||
# Fast, SDK-free parity check for translation resources, so Weblate PRs (which
|
||||
# only touch values-*/strings.xml) get quick feedback without the full Android
|
||||
# build. The deeper checks still run in CI via lintDebug (ExtraTranslation).
|
||||
#
|
||||
# Runs on every PR (no path filter) so the required "Translations / check"
|
||||
# status is always reported — like the `ci` job. A path-filtered workflow is
|
||||
# skipped on unrelated PRs and never posts its status, which leaves that
|
||||
# required check pending forever and blocks the merge of any code-only PR into a
|
||||
# release/* branch. The check itself is cheap and simply passes when the
|
||||
# committed translations are consistent, so always running it costs nothing.
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: translations-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Ensure python3
|
||||
run: |
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
apt-get update && apt-get install -y python3
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
apk add --no-cache python3
|
||||
fi
|
||||
fi
|
||||
python3 --version
|
||||
|
||||
- name: Check translation parity
|
||||
run: python3 scripts/check_translations.py
|
||||
@@ -3,12 +3,18 @@ name: Release — F-Droid repo + Gitea/Codeberg release
|
||||
# A release is cut by merging a release branch into main with a bumped
|
||||
# versionName (see docs/RELEASING.md). This workflow reads that versionName and,
|
||||
# if no matching tag exists yet, runs tests, builds + signs the APK, publishes
|
||||
# it to the F-Droid repo, creates the vX.Y.Z tag + Gitea release, and mirrors
|
||||
# that release to Codeberg with the signed APK + a SHA-256 checksum as a
|
||||
# it to the F-Droid repo, creates the vX.Y.Z tag + Gitea release, and publishes
|
||||
# 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
|
||||
# trigger. Ordinary merges (no version bump) fall through `detect` and do
|
||||
# nothing.
|
||||
#
|
||||
# This file lives in .gitea/workflows on purpose: Codeberg is canonical for git,
|
||||
# issues, PRs and releases, but every secret (app key, F-Droid repo key, Hetzner
|
||||
# credentials) lives on the self-hosted Gitea instance, and this is the only
|
||||
# directory Codeberg cannot see. Contributor-triggerable work lives in
|
||||
# .forgejo/workflows and references no secret. See docs/RELEASING.md.
|
||||
#
|
||||
# A manual workflow_dispatch (from a branch) runs the re-sign-only recovery
|
||||
# path: it re-signs the existing F-Droid index with the repo key and re-uploads,
|
||||
# without building an APK or creating a release. Used for key rotation / repo
|
||||
@@ -27,6 +33,14 @@ jobs:
|
||||
# whether this push actually cuts a new release (no tag for it yet). Keeps the
|
||||
# heavy job from running on every merge to main.
|
||||
detect:
|
||||
# Gitea only. The workflow directory split already keeps this file invisible
|
||||
# to Codeberg — Forgejo's lookup is first-match-wins, and .forgejo/workflows
|
||||
# exists — but that only holds while .forgejo/ is non-empty. Move the last
|
||||
# file out of it and Codeberg would fall back to .gitea/workflows and start
|
||||
# running the release pipeline on the contributor-facing runner, with no
|
||||
# secrets. repository_owner differs between the two forges regardless of
|
||||
# URL, proxy or instance rename, so this closes it permanently.
|
||||
if: github.repository_owner == 'makiolaj'
|
||||
runs-on: docker
|
||||
outputs:
|
||||
is_release: ${{ steps.v.outputs.is_release }}
|
||||
@@ -42,8 +56,16 @@ jobs:
|
||||
- name: Resolve version and whether it is a new release
|
||||
id: v
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
# Tags are read from Codeberg, which is canonical — deliberately NOT
|
||||
# from the Gitea API this workflow runs on. The Codeberg -> Gitea sync
|
||||
# is a push mirror, i.e. `git push --mirror`, which deletes refs the
|
||||
# source does not have. A tag minted here on Gitea is therefore wiped
|
||||
# by the next sync (Codeberg does not have it yet) and only reappears
|
||||
# once the tag push at the end of this workflow propagates back.
|
||||
# Asking Gitea inside that window would report "no tag" for a release
|
||||
# that already shipped, and cut it a second time.
|
||||
# Public repo, so this read needs no token.
|
||||
TAG_API: https://codeberg.org/api/v1/repos/jlmakiola/agendula
|
||||
run: |
|
||||
set -e
|
||||
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
|
||||
@@ -65,15 +87,28 @@ jobs:
|
||||
fi
|
||||
# A tag for this version already existing means the release shipped on
|
||||
# an earlier push; do nothing. Absent => this merge cuts the release.
|
||||
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
|
||||
-H "Authorization: token $TOKEN" "$API/git/refs/tags/v$VERSION")
|
||||
if [ "$STATUS" = "200" ]; then
|
||||
echo "Tag v$VERSION already exists — nothing to release."
|
||||
echo "is_release=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "No tag for v$VERSION yet — cutting the release."
|
||||
echo "is_release=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
#
|
||||
# Anything other than a clean 200/404 is treated as fatal rather than
|
||||
# as "no tag". A Codeberg outage or a network blip would otherwise
|
||||
# read as absent and re-cut a release that has already shipped —
|
||||
# republishing to F-Droid. Failing here is recoverable; a duplicate
|
||||
# release is not.
|
||||
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$TAG_API/git/refs/tags/v$VERSION" || echo 000)
|
||||
case "$STATUS" in
|
||||
200)
|
||||
echo "Tag v$VERSION already exists on Codeberg — nothing to release."
|
||||
echo "is_release=false" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
404)
|
||||
echo "No tag for v$VERSION on Codeberg yet — cutting the release."
|
||||
echo "is_release=true" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
*)
|
||||
echo "Codeberg tag lookup for v$VERSION returned HTTP $STATUS." >&2
|
||||
echo "Refusing to guess: treating this as 'no tag' could re-cut a shipped release." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Releases: build + sign + publish, then mint the tag and Gitea release.
|
||||
# Also runs on manual dispatch, where it skips the build and just re-signs and
|
||||
@@ -360,17 +395,21 @@ jobs:
|
||||
-F "attachment=@/tmp/$ASSET" \
|
||||
"$API/releases/$ID/assets?name=$ASSET" -o /dev/null -w "asset upload HTTP %{http_code}\n"
|
||||
|
||||
# Mirror the release to the Codeberg mirror as a direct-download channel
|
||||
# for users who don't want F-Droid. Gitea already push-mirrors branches +
|
||||
# tags to Codeberg, but releases aren't git objects so they don't sync —
|
||||
# we create the release there over the API and attach the signed APK plus
|
||||
# a SHA-256 checksum. The APK is identical to the F-Droid one (same app
|
||||
# key), so this adds no trust surface. Best-effort: a Codeberg outage
|
||||
# (it 504s under load) must never fail an already-published F-Droid
|
||||
# release. Needs the CODEBERG_RELEASE_TOKEN secret; skips cleanly if unset.
|
||||
# Publish the release on Codeberg, which is canonical for tags and
|
||||
# releases (see docs/RELEASING.md). Codeberg push-mirrors branches + tags
|
||||
# to Gitea, but releases aren't git objects and don't sync in either
|
||||
# direction — so this step pushes the tag straight to Codeberg and creates
|
||||
# the release there over the API, attaching the signed APK plus a SHA-256
|
||||
# checksum as the direct-download channel for users who don't want
|
||||
# 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
|
||||
if: env.IS_RELEASE == 'true'
|
||||
continue-on-error: true
|
||||
# NOT continue-on-error: this step reported green through 0.2.1, 0.2.2,
|
||||
# 0.3.0, 0.3.1 and 0.3.2 while never once publishing, which is how a
|
||||
# crash-fix release reached F-Droid but not the Codeberg/Obtainium
|
||||
# users who needed it. A broken mirror must fail the release loudly.
|
||||
env:
|
||||
TOKEN: ${{ secrets.CODEBERG_RELEASE_TOKEN }}
|
||||
API: https://codeberg.org/api/v1/repos/jlmakiola/agendula
|
||||
@@ -399,41 +438,58 @@ jobs:
|
||||
sed -i -e '/./,$!d' release-notes.md
|
||||
fi
|
||||
[ -s release-notes.md ] || echo "_See CHANGELOG.md for ${VERSION}._" > release-notes.md
|
||||
# The push mirror (sync_on_commit) usually syncs the tag to Codeberg
|
||||
# before this step runs. Forgejo 500s on POST /releases with a
|
||||
# target_commitish when the tag already exists — so only pass
|
||||
# target_commitish when we actually need the API to create the tag.
|
||||
TAG_CODE=$(curl -s -o /dev/null -w '%{http_code}' \
|
||||
-H "Authorization: token $TOKEN" "$API/git/refs/tags/$TAG")
|
||||
python3 - "$TAG" "$SHA" "$PRERELEASE" "$TAG_CODE" <<'PY' > cb-payload.json
|
||||
# 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, sha, pre, tag_code = sys.argv[1:5]
|
||||
payload = {
|
||||
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",
|
||||
}
|
||||
# Only create the tag via the release when it isn't mirrored yet.
|
||||
if tag_code != "200":
|
||||
payload["target_commitish"] = sha
|
||||
print(json.dumps(payload))
|
||||
}))
|
||||
PY
|
||||
# Upsert (re-run safe).
|
||||
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
|
||||
if [ -n "$ID" ]; then
|
||||
curl -s -o /dev/null -w "release PATCH HTTP %{http_code}\n" -X PATCH \
|
||||
# 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/$ID"
|
||||
else
|
||||
curl -s -o cb-response.json -w "release POST HTTP %{http_code}\n" -X POST \
|
||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||
-d @cb-payload.json "$API/releases"
|
||||
-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)
|
||||
fi
|
||||
if [ -z "$ID" ]; then echo "Could not resolve Codeberg release id." >&2; exit 1; fi
|
||||
[ -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
|
||||
|
||||
61
.gitea/workflows/renovate.yml
Normal file
61
.gitea/workflows/renovate.yml
Normal file
@@ -0,0 +1,61 @@
|
||||
name: Renovate
|
||||
|
||||
on:
|
||||
# Weekly sweep. Mondays 05:00 UTC — this cron owns the cadence; the repo's
|
||||
# renovate.json5 deliberately has no internal schedule (avoids double-gating).
|
||||
schedule:
|
||||
- cron: '0 5 * * 1'
|
||||
# Manual run for an on-demand sweep from the Actions tab.
|
||||
workflow_dispatch:
|
||||
|
||||
# Never let two Renovate runs touch the repo at once.
|
||||
concurrency:
|
||||
group: renovate
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
renovate:
|
||||
# Gitea only — same guard, and the same reason, as release.yaml's `detect`:
|
||||
# this file is invisible to Codeberg only while .forgejo/ is non-empty, and
|
||||
# a repo-write token must never run on the contributor-facing runner.
|
||||
if: github.repository_owner == 'makiolaj'
|
||||
runs-on: docker
|
||||
# Run the Renovate image *as* the job container and invoke the `renovate`
|
||||
# binary directly. The renovatebot/github-action wrapper is a thin Node
|
||||
# action that shells out to `docker run …` — it needs a Docker CLI + socket
|
||||
# inside the job, which the Gitea runner's plain node container has not, so
|
||||
# it died on "Unable to locate executable file: docker". Running the image
|
||||
# directly drops the docker-in-docker requirement entirely.
|
||||
# Full tag pinned; Renovate's github-actions manager keeps it bumped.
|
||||
container:
|
||||
image: ghcr.io/renovatebot/renovate:43.232.0
|
||||
steps:
|
||||
- name: Run Renovate
|
||||
run: renovate
|
||||
env:
|
||||
# Renovate targets Codeberg (canonical) while still RUNNING on the
|
||||
# Gitea runner. Moving the job to Codeberg would put a repo-write
|
||||
# token on the contributor-facing runner, which is exactly what the
|
||||
# .forgejo/ vs .gitea/ split exists to prevent — so the token stays
|
||||
# where the other secrets live and only the API calls cross over.
|
||||
#
|
||||
# Platform is `forgejo`, not `gitea`: Codeberg runs Forgejo, and the
|
||||
# pinned image ships a distinct forgejo platform module.
|
||||
RENOVATE_PLATFORM: forgejo
|
||||
RENOVATE_ENDPOINT: https://codeberg.org/api/v1
|
||||
# Codeberg bot-account token (Gitea secret). Needs repo read/write +
|
||||
# PR scope on jlmakiola/agendula.
|
||||
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||
# Scope to this repo only — no org-wide autodiscovery.
|
||||
RENOVATE_AUTODISCOVER: 'false'
|
||||
RENOVATE_REPOSITORIES: '["jlmakiola/agendula"]'
|
||||
# Commits/PRs authored as the bot, not a real maintainer. This address
|
||||
# must be a verified email on the Codeberg bot account, otherwise the
|
||||
# commits show up unattributed there.
|
||||
RENOVATE_GIT_AUTHOR: 'Renovate Bot <renovate@jeanlucmakiola.de>'
|
||||
# Read-only github.com PAT (no scopes needed). Nearly every dependency
|
||||
# is *released* on GitHub, and without this, changelog/release-note
|
||||
# lookups hit the 60/h anonymous rate limit and PRs arrive with an
|
||||
# empty "Release Notes" section.
|
||||
RENOVATE_GITHUB_COM_TOKEN: ${{ secrets.GITHUB_COM_TOKEN }}
|
||||
LOG_LEVEL: info
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -53,5 +53,19 @@ Thumbs.db
|
||||
# F-Droid local artifacts (the pipeline generates them in CI)
|
||||
/fdroid/
|
||||
|
||||
# Release-pipeline scratch files. release.yaml writes these into the workspace
|
||||
# while cutting a release; a self-hosted runner reuses that workspace, so they
|
||||
# must never end up committed (release-notes.md did, through 0.3.2).
|
||||
/release-notes.md
|
||||
/payload.json
|
||||
/existing.json
|
||||
/response.json
|
||||
/cb-payload.json
|
||||
/cb-response.json
|
||||
|
||||
# KSP
|
||||
.ksp/
|
||||
|
||||
# Local agent notes: machine-specific build setup and on-device rules, not
|
||||
# anything the project itself depends on.
|
||||
/CLAUDE.md
|
||||
|
||||
2
.gitmodules
vendored
2
.gitmodules
vendored
@@ -1,3 +1,3 @@
|
||||
[submodule "floret-kit"]
|
||||
path = floret-kit
|
||||
url = https://gitea.jeanlucmakiola.de/makiolaj/floret-kit.git
|
||||
url = https://codeberg.org/jlmakiola/floret-kit.git
|
||||
|
||||
57
CHANGELOG.md
57
CHANGELOG.md
@@ -8,22 +8,47 @@ All notable changes to this project are documented here. The format follows
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- M5 reminders onboarding & polish: a one-time reminder onboarding step after the
|
||||
provider grant explains that Agendula delivers due reminders itself and requests
|
||||
`POST_NOTIFICATIONS` (API 33+). A new **Settings** screen (gear on the lists
|
||||
overview), structured after Calendula as a category hub with sliding sub-screens
|
||||
(`CollapsingScaffold` + grouped category rows + full-screen `OptionPicker`):
|
||||
an About card, **Appearance** (theme, dynamic colour), **Task form** (which
|
||||
optional edit-form fields show by default, default list, the add-a-subtask-row
|
||||
opt-out), and **Reminders** (a master enable switch that re-requests the
|
||||
notification permission, a "when to remind" default offset, and — on Android 12
|
||||
only — an exact-alarm status row that deep-links to system settings).
|
||||
- The reminders master switch gates the whole engine: turning it off clears every
|
||||
scheduled alarm and suppresses any that fire.
|
||||
- Expressive top-bar actions: a reusable `ShapedActionButton` wraps an action icon
|
||||
in a tonal M3 Expressive `MaterialShapes` container (the settings entry uses the
|
||||
faceted Gem shape, tertiary tones), with a small press scale/rotate flourish.
|
||||
Future top-bar actions pick their own shape from the `ActionShapes` registry.
|
||||
- Agendula can now be translated. Pick or request a language on Weblate and
|
||||
translate in the browser — the link sits at the top of the language picker in
|
||||
**Settings → App language**. Partial translations are fine; anything
|
||||
untranslated falls back to English.
|
||||
|
||||
### Changed
|
||||
- Agendula's home is now **Codeberg** (`jlmakiola/agendula`) — that's where the
|
||||
source, issues, pull requests and releases live. The Source and License links
|
||||
in Settings, the issue-reporting link and the F-Droid metadata all point there
|
||||
now. The self-hosted Gitea instance stays as build infrastructure.
|
||||
|
||||
## [0.3.2] - 2026-07-20
|
||||
|
||||
### Fixed
|
||||
- Releases reach the Codeberg download channel again. 0.3.1 published to
|
||||
F-Droid but never appeared on Codeberg, so if you install from there — or
|
||||
through Obtainium — this is the release that finally carries 0.3.0's
|
||||
launch-crash fix. The app itself is unchanged from 0.3.1.
|
||||
|
||||
## [0.3.1] - 2026-07-20
|
||||
|
||||
### Fixed
|
||||
- Agendula no longer crashes on launch. Every 0.3.0 install was affected: the
|
||||
release build stripped a constructor that the background-work scheduler needs
|
||||
to open its database, and that happens before the app draws anything.
|
||||
|
||||
## [0.3.0] - 2026-07-19
|
||||
|
||||
### Added
|
||||
- Reminders: Agendula now delivers your due reminders itself. A one-time setup
|
||||
step explains this and asks for notification access, and a master switch in
|
||||
Settings turns the whole thing off again.
|
||||
- A Settings screen, from the gear on the overview: appearance and theme, which
|
||||
fields the task form shows, your default list, and reminder defaults.
|
||||
- The overview leads with Today — a progress ring showing how much of today
|
||||
you've finished — followed by a live preview of what's coming up next.
|
||||
- Search across every task, open or completed, from the top bar.
|
||||
- A proper app icon.
|
||||
|
||||
### Changed
|
||||
- A tidier top bar: no app title, with search and settings pinned to the right.
|
||||
|
||||
## [0.2.2] - 2026-07-19
|
||||
|
||||
|
||||
@@ -33,10 +33,33 @@ would expose provider details to a ViewModel or the UI, it's in the wrong layer.
|
||||
./gradlew lintDebug # Android lint (CI runs this on every PR)
|
||||
```
|
||||
|
||||
CI (`.gitea/workflows/ci.yaml`) runs a reproducible-release invariant check,
|
||||
CI (`.forgejo/workflows/ci.yaml`, on Codeberg) runs a reproducible-release invariant check,
|
||||
then lint → unit tests → debug build on every pull request, so run these locally
|
||||
before opening a PR. Keep CI green.
|
||||
|
||||
## Translations
|
||||
|
||||
**Never edit a `values-*/strings.xml` file in a pull request.** Translations are
|
||||
owned by a self-hosted Weblate that writes to this repository directly, and a
|
||||
hand-edit is overwritten on the next sync.
|
||||
|
||||
→ **[Translate Agendula on Weblate](https://weblate.dev.jeanlucmakiola.de/engage/agendula/)**
|
||||
|
||||
Adding a *new* English string to `values/strings.xml` is normal PR work; Weblate
|
||||
picks it up and offers it to translators. Partial translations are expected and
|
||||
fine — missing keys are informational. Stale and orphaned keys are not, so run
|
||||
|
||||
```sh
|
||||
python3 scripts/check_translations.py
|
||||
```
|
||||
|
||||
before pushing. It reports those more clearly than lint's `MissingTranslation`
|
||||
does, and it's what the `Translations` check runs on every PR.
|
||||
|
||||
A new language also needs one `<locale>` line in
|
||||
`app/src/main/res/xml/locales_config.xml` — that file is the single source of
|
||||
truth for both the in-app picker and the Android 13+ per-app language setting.
|
||||
|
||||
## Where to put code
|
||||
|
||||
| Layer | Lives in | Rule of thumb |
|
||||
|
||||
71
README.md
71
README.md
@@ -3,9 +3,10 @@
|
||||
<h1>Agendula</h1>
|
||||
|
||||
<p><strong>A modern Material 3 Expressive task app for Android.</strong><br>
|
||||
Reads, writes, and reminds — on top of an existing tasks provider, with no own
|
||||
sync stack.</p>
|
||||
Keeps your tasks on your device, or on top of a tasks provider you already use.
|
||||
Open standards, no account required.</p>
|
||||
|
||||
<a href="https://codeberg.org/jlmakiola/agendula/actions"><img src="https://codeberg.org/jlmakiola/agendula/actions/workflows/ci.yaml/badge.svg?branch=main" alt="CI"></a>
|
||||
<img src="https://img.shields.io/badge/Android-10%2B-3DDC84?logo=android&logoColor=white" alt="Android 10+">
|
||||
<img src="https://img.shields.io/badge/Kotlin-Compose-7F52FF?logo=kotlin&logoColor=white" alt="Kotlin + Compose">
|
||||
<img src="https://img.shields.io/badge/Material%203-Expressive-4285F4" alt="Material 3 Expressive">
|
||||
@@ -13,32 +14,66 @@ sync stack.</p>
|
||||
|
||||
</div>
|
||||
|
||||
Agendula is the task-list sibling to [Calendula](https://gitea.jeanlucmakiola.de/makiolaj/calendula).
|
||||
Where Calendula is a pure front-end over Android's `CalendarContract`, Agendula is
|
||||
a pure front-end over the **OpenTasks `TaskContract` provider** — the store that
|
||||
DAVx5 (and SmoothSync, DecSync, …) syncs your CalDAV `VTODO` tasks into. No own
|
||||
database, no reinvented sync.
|
||||
Agendula is the task-list sibling to [Calendula](https://codeberg.org/jlmakiola/calendula).
|
||||
Where Calendula is a pure front-end over Android's `CalendarContract`, Agendula
|
||||
keeps its own store, designed around RFC 5545's `VTODO` — the same tasks DAVx5
|
||||
(and SmoothSync, DecSync, …) sync out of your CalDAV server. It can also read and
|
||||
write a tasks provider you already have, for anyone already syncing that way.
|
||||
|
||||
The name rhymes with its sibling on purpose: **Agendula** is *agenda* — Latin for
|
||||
“things to be done” — given Calendula's `-ula` ending. Calendula keeps your days;
|
||||
Agendula keeps your to-dos. (A Calendula flower head is botanically a cluster of
|
||||
many small *florets* — so the two apps are florets of one bloom.)
|
||||
|
||||
> **Status: data layer done, UI in progress.** The full non-visual stack over
|
||||
> the `TaskContract` provider — provider resolution, live-updating reads,
|
||||
> writes, smart-list filtering, and a self-scheduled reminder engine — is built
|
||||
> and unit-tested. The Material 3 Expressive screens are now being built on top,
|
||||
> one at a time. See [`docs/ROADMAP.md`](docs/ROADMAP.md) for status,
|
||||
## Where your tasks live — your choice
|
||||
|
||||
| | Where | Sync | Needs |
|
||||
|---|---|---|---|
|
||||
| **On your device** *(default)* | Agendula's own database | none yet — CalDAV sync of our own is planned | nothing. No account, no permissions, no other app |
|
||||
| **In a provider you already use** | OpenTasks or tasks.org | whatever syncs it for you — DAVx5 and friends | that app installed, and its read/write permission |
|
||||
|
||||
Agendula's own store is an ordinary app database — nothing is published to other
|
||||
apps, so there is no authority to clash over and no permission to grant. It
|
||||
**coexists with OpenTasks rather than replacing it**: installing one never breaks
|
||||
the other, and if you already sync through a provider, that keeps working exactly
|
||||
as it did.
|
||||
|
||||
Recurring tasks are expanded per RFC 5545, and everything the schema does not
|
||||
model is round-tripped verbatim rather than dropped — so passing your tasks
|
||||
through Agendula does not quietly lose fields a server sent.
|
||||
|
||||
Your tasks are exportable as standard iCalendar `.ics` files at any time, because
|
||||
data you can't take with you isn't really yours.
|
||||
|
||||
> **Status: backend complete, UI catching up.** Storage, reads and
|
||||
> writes, smart-list filtering, a self-scheduled reminder engine, and export are
|
||||
> built and unit-tested. The Material 3 Expressive screens are being built on
|
||||
> top, one at a time — the storage-mode picker and export screen are not there
|
||||
> yet. See [`docs/ROADMAP.md`](docs/ROADMAP.md) for status,
|
||||
> [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for how it's built, and
|
||||
> [`docs/PLAN.md`](docs/PLAN.md) for the A-now-B-later design rationale.
|
||||
> [`docs/STORAGE-AND-SYNC.md`](docs/STORAGE-AND-SYNC.md) for why storage works
|
||||
> the way it does.
|
||||
|
||||
## Sync sources (by design)
|
||||
|
||||
Agendula works with anything that writes to the tasks provider — **DAVx5**
|
||||
(CalDAV), **SmoothSync**, **CalDAV-Sync**, **DecSync CC**, or any Android sync
|
||||
adapter — because it builds on the provider, not on any one sync app. Google
|
||||
Tasks / Microsoft To Do are out of scope by design (proprietary; they would mean
|
||||
owning a sync stack). Open standards — CalDAV / iCalendar / DecSync — are the lane.
|
||||
In external-provider mode Agendula works with anything that writes to that provider —
|
||||
**DAVx5** (CalDAV), **SmoothSync**, **CalDAV-Sync**, **DecSync CC**, or any
|
||||
Android sync adapter — because it builds on the provider, not on any one sync
|
||||
app. Google Tasks / Microsoft To Do are out of scope by design (proprietary; they
|
||||
would mean owning a sync stack). Open standards — CalDAV / iCalendar / DecSync —
|
||||
are the lane.
|
||||
|
||||
## Translations
|
||||
|
||||
Agendula ships in English so far, and would like not to. Translations are
|
||||
managed on a self-hosted **Weblate**, and partial ones are fine — an
|
||||
untranslated string simply falls back to English.
|
||||
|
||||
**→ [Help translate Agendula](https://weblate.dev.jeanlucmakiola.de/engage/agendula/)**
|
||||
|
||||
No coding needed: register on the Weblate server, pick (or request) a language,
|
||||
and translate the strings in your browser. You can also reach this link in the
|
||||
app from the top of **Settings → App language**.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -29,8 +29,8 @@ android {
|
||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||
// PATCH from versionName, e.g. 0.2.0 -> 200). The Gitea release is marked
|
||||
// as a pre-release while MAJOR is 0. See docs/RELEASING.md.
|
||||
versionCode = 202
|
||||
versionName = "0.2.2"
|
||||
versionCode = 302
|
||||
versionName = "0.3.2"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -111,12 +111,28 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
lint {
|
||||
// Community translations are expected to be partial — a missing string
|
||||
// falls back to the English base at runtime — so don't fail the build on
|
||||
// it. Likewise a translated <plurals> may not fill every CLDR quantity
|
||||
// form its locale defines (e.g. Arabic needs "zero"); the missing form
|
||||
// falls back to "other" at runtime, so MissingQuantity is informational
|
||||
// too. Stale/extra keys (ExtraTranslation) stay fatal; scripts/
|
||||
// check_translations.py guards the same invariants with clearer,
|
||||
// translator-facing messages.
|
||||
informational += listOf("MissingTranslation", "MissingQuantity")
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests {
|
||||
all { it.useJUnitPlatform() }
|
||||
isReturnDefaultValues = true
|
||||
}
|
||||
}
|
||||
|
||||
// MigrationTestHelper reads the exported schemas out of the test APK's
|
||||
// assets, so app/schemas/ has to ship with the instrumented tests.
|
||||
sourceSets.getByName("androidTest").assets.srcDir("$projectDir/schemas")
|
||||
}
|
||||
|
||||
kotlin {
|
||||
@@ -125,11 +141,27 @@ kotlin {
|
||||
}
|
||||
}
|
||||
|
||||
// Export each Room schema version to app/schemas/ and commit it. That JSON is
|
||||
// what MigrationTestHelper reads to build an old database and migrate it, so
|
||||
// without it a migration can only be tested by hand.
|
||||
ksp {
|
||||
arg("room.schemaLocation", "$projectDir/schemas")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Not a dependency we use directly — lifecycle already drags it in at 1.7.3.
|
||||
// AGP's consistent resolution then pins androidTest to the app classpath, and
|
||||
// room-testing's MigrationTestHelper needs 1.8+ to deserialize the exported
|
||||
// schema; on 1.7.3 it dies with an AbstractMethodError. Raise it in one place.
|
||||
constraints {
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
}
|
||||
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.lifecycle.process)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
@@ -145,7 +177,17 @@ dependencies {
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
ksp(libs.hilt.compiler)
|
||||
|
||||
// RFC 5545 recurrence expansion, in-process. Pinned at 0.12.2 — 0.16.0
|
||||
// removed RecurrenceSet. rfc5545-datetime comes with it and is part of its
|
||||
// API surface, so it isn't declared separately.
|
||||
implementation(libs.dmfs.lib.recur)
|
||||
|
||||
implementation(libs.androidx.room.runtime)
|
||||
implementation(libs.androidx.room.ktx)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.androidx.documentfile)
|
||||
|
||||
implementation(libs.androidx.glance.appwidget)
|
||||
implementation(libs.androidx.glance.material3)
|
||||
@@ -173,6 +215,7 @@ dependencies {
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
androidTestImplementation(libs.androidx.test.rules)
|
||||
androidTestImplementation(libs.truth)
|
||||
androidTestImplementation(libs.androidx.room.testing)
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.ui.test.junit4)
|
||||
}
|
||||
|
||||
15
app/proguard-rules.pro
vendored
15
app/proguard-rules.pro
vendored
@@ -2,5 +2,20 @@
|
||||
-keep class dagger.hilt.** { *; }
|
||||
-keep @dagger.hilt.android.HiltAndroidApp class *
|
||||
|
||||
# Room instantiates its generated <Database>_Impl reflectively through a no-arg
|
||||
# constructor. R8 under AGP 9 keeps the class but prunes that constructor, since
|
||||
# nothing calls it directly — Room then throws InstantiationException, reported
|
||||
# as "Failed to create an instance of ...". We pull Room in transitively via
|
||||
# Glance -> WorkManager, whose WorkDatabase is built by WorkManagerInitializer
|
||||
# at startup, so the app died on launch in every minified build (issue #1).
|
||||
-keep class * extends androidx.room.RoomDatabase { <init>(); }
|
||||
|
||||
# WorkManager likewise looks its workers up by name and calls this constructor
|
||||
# reflectively — same pruning, but it only bites once a worker actually runs
|
||||
# (Glance's widget updates), so keep it explicitly rather than wait for it.
|
||||
-keep class * extends androidx.work.ListenableWorker {
|
||||
<init>(android.content.Context, androidx.work.WorkerParameters);
|
||||
}
|
||||
|
||||
# Compose Compiler may keep its own; defaults are fine
|
||||
-dontwarn org.jetbrains.annotations.**
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 1,
|
||||
"identityHash": "c94852274d874fe255ee76e1e46a3003",
|
||||
"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, `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": "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, 'c94852274d874fe255ee76e1e46a3003')"
|
||||
]
|
||||
}
|
||||
}
|
||||
BIN
app/src/androidTest/assets/tasks-v23.db
Normal file
BIN
app/src/androidTest/assets/tasks-v23.db
Normal file
Binary file not shown.
@@ -0,0 +1,290 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.legacy
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.room.Room
|
||||
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 de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The one-shot import, against `assets/tasks-v23.db` — the dmfs v23 fixture
|
||||
* `scripts/make_import_fixture.py` seeds. Instrumented because both halves need
|
||||
* a real SQLite: the source file and Room.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class OneShotImportTest {
|
||||
|
||||
@get:Rule
|
||||
val temp = TemporaryFolder()
|
||||
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
private lateinit var scope: CoroutineScope
|
||||
private lateinit var prefs: DataStore<Preferences>
|
||||
private lateinit var db: TasksDatabase
|
||||
private lateinit var importer: OneShotImport
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
prefs = PreferenceDataStoreFactory.create(scope = scope) {
|
||||
temp.newFile("import-${counter++}.preferences_pb").also(File::delete)
|
||||
}
|
||||
db = Room.inMemoryDatabaseBuilder(context, TasksDatabase::class.java)
|
||||
.allowMainThreadQueries()
|
||||
.build()
|
||||
importer = OneShotImport(context, db, prefs)
|
||||
legacyFile().delete()
|
||||
archiveFile().delete()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
db.close()
|
||||
scope.cancel()
|
||||
legacyFile().delete()
|
||||
archiveFile().delete()
|
||||
}
|
||||
|
||||
private fun legacyFile() = context.getDatabasePath(OneShotImport.LEGACY_NAME)
|
||||
private fun archiveFile() = context.getDatabasePath(OneShotImport.ARCHIVE_NAME)
|
||||
|
||||
/** The fixture, copied out of the test APK's assets. */
|
||||
private fun fixture(target: File = temp.newFile("tasks-v23-copy.db")): File {
|
||||
InstrumentationRegistry.getInstrumentation().context.assets.open(FIXTURE).use { source ->
|
||||
target.outputStream().use(source::copyTo)
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
private fun taskRows(): Map<String, TaskEntity> =
|
||||
db.tasks().tasks(null, includeCompleted = true).associate { it.task.title!! to it.task }
|
||||
|
||||
// --- what lands -----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun importsEveryLiveTaskAndLeavesTheDeletedOneBehind() {
|
||||
val counts = importer.importFrom(fixture())
|
||||
|
||||
assertThat(counts).isEqualTo(ImportCounts(lists = 3, tasks = 8, alarms = 2))
|
||||
assertThat(taskRows().keys).containsExactly(
|
||||
"Buy milk",
|
||||
"Call the dentist",
|
||||
"Gather receipts",
|
||||
"Renew domain",
|
||||
"Water the plants",
|
||||
"Team offsite",
|
||||
"Task in a hidden list",
|
||||
"Ship the release",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun importsEveryListAsADeviceOnlyListWithItsFlags() {
|
||||
importer.importFrom(fixture())
|
||||
|
||||
val lists = db.taskLists().lists().associateBy { it.list.name }
|
||||
assertThat(lists.keys).containsExactly("Personal", "Hidden list", "Work")
|
||||
assertThat(lists.values.map { it.list.accountId }).containsExactly(null, null, null)
|
||||
assertThat(lists.getValue("Personal").list.isVisible).isTrue()
|
||||
assertThat(lists.getValue("Hidden list").list.isVisible).isFalse()
|
||||
// The list that sat under a real account: still imported, owner kept.
|
||||
assertThat(lists.getValue("Work").list.owner).isEqualTo("Me")
|
||||
assertThat(lists.getValue("Work").list.color).isEqualTo(0xFF2244AA.toInt())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun carriesTheTaskFieldsAcross() {
|
||||
importer.importFrom(fixture())
|
||||
val tasks = taskRows()
|
||||
|
||||
val milk = tasks.getValue("Buy milk")
|
||||
assertThat(milk.due).isEqualTo(Instant.fromEpochMilliseconds(T0 + DAY))
|
||||
assertThat(milk.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||
assertThat(milk.createdAt).isEqualTo(Instant.fromEpochMilliseconds(T0))
|
||||
|
||||
val dentist = tasks.getValue("Call the dentist")
|
||||
assertThat(dentist.status).isEqualTo(TaskStatus.IN_PROCESS)
|
||||
assertThat(dentist.percentComplete).isEqualTo(40)
|
||||
|
||||
val domain = tasks.getValue("Renew domain")
|
||||
assertThat(domain.status).isEqualTo(TaskStatus.COMPLETED)
|
||||
assertThat(domain.completedAt).isEqualTo(Instant.fromEpochMilliseconds(T0 - DAY))
|
||||
|
||||
val plants = tasks.getValue("Water the plants")
|
||||
assertThat(plants.rrule).isEqualTo("FREQ=WEEKLY;BYDAY=MO,TH")
|
||||
assertThat(plants.timezone).isEqualTo("Europe/Berlin")
|
||||
assertThat(plants.dtstart).isEqualTo(Instant.fromEpochMilliseconds(T0))
|
||||
|
||||
assertThat(tasks.getValue("Team offsite").isAllDay).isTrue()
|
||||
}
|
||||
|
||||
// --- uids -----------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun keepsExistingUidsAndMintsOneWhereTheLegacyRowHadNone() {
|
||||
importer.importFrom(fixture())
|
||||
val tasks = taskRows()
|
||||
|
||||
assertThat(tasks.getValue("Buy milk").uid).isEqualTo("a1b2c3d4-0000-4000-8000-000000000001")
|
||||
// The external-account row's uid is what lets it be re-attached later.
|
||||
assertThat(tasks.getValue("Ship the release").uid)
|
||||
.isEqualTo("a1b2c3d4-0000-4000-8000-000000000009")
|
||||
|
||||
val minted = tasks.getValue("Call the dentist").uid
|
||||
assertThat(minted).isNotEmpty()
|
||||
assertThat(UUID.fromString(minted).version()).isEqualTo(4)
|
||||
assertThat(tasks.values.map { it.uid }.toSet()).hasSize(tasks.size)
|
||||
}
|
||||
|
||||
// --- the id remap ---------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun remapsListIdsOntoTheNewRowIds() {
|
||||
importer.importFrom(fixture())
|
||||
|
||||
val lists = db.taskLists().lists().associateBy { it.list.name }
|
||||
val byList = db.tasks().tasks(null, includeCompleted = true)
|
||||
.groupBy { it.task.listId }
|
||||
.mapValues { (_, rows) -> rows.size }
|
||||
|
||||
assertThat(byList[lists.getValue("Personal").list.id]).isEqualTo(6)
|
||||
assertThat(byList[lists.getValue("Hidden list").list.id]).isEqualTo(1)
|
||||
assertThat(byList[lists.getValue("Work").list.id]).isEqualTo(1)
|
||||
// No task kept a dmfs row id that Room never handed out.
|
||||
assertThat(byList.keys).containsExactlyElementsIn(lists.values.map { it.list.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun remapsParentIdsOntoTheNewRowIds() {
|
||||
importer.importFrom(fixture())
|
||||
val tasks = taskRows()
|
||||
|
||||
val parent = tasks.getValue("Buy milk")
|
||||
val child = tasks.getValue("Gather receipts")
|
||||
assertThat(child.parentId).isEqualTo(parent.id)
|
||||
assertThat(db.tasks().subtasks(parent.id).map { it.task.title }).containsExactly("Gather receipts")
|
||||
assertThat(tasks.values.filter { it.parentId != null }).hasSize(1)
|
||||
}
|
||||
|
||||
// --- alarms ---------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun importsAlarmsAndSkipsEveryOtherProperty() {
|
||||
importer.importFrom(fixture())
|
||||
val tasks = taskRows()
|
||||
|
||||
assertThat(db.alarms().all()).hasSize(2)
|
||||
|
||||
val milk = db.alarms().forTask(tasks.getValue("Buy milk").id).single()
|
||||
assertThat(milk.minutesBefore).isEqualTo(30)
|
||||
assertThat(milk.reference).isEqualTo(AlarmReference.DUE)
|
||||
assertThat(milk.message).isNull()
|
||||
|
||||
val release = db.alarms().forTask(tasks.getValue("Ship the release").id).single()
|
||||
assertThat(release.minutesBefore).isEqualTo(1440)
|
||||
assertThat(release.reference).isEqualTo(AlarmReference.DUE)
|
||||
assertThat(release.message).isEqualTo("Ship it")
|
||||
|
||||
// The category property on task 1 is not an alarm.
|
||||
assertThat(db.alarms().all().map { it.message }).doesNotContain("Errands")
|
||||
}
|
||||
|
||||
// --- running it -----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun runIfNeededImportsArchivesTheSourceAndThenDoesNothing() = runBlocking {
|
||||
fixture(legacyFile())
|
||||
|
||||
val first = importer.runIfNeeded()
|
||||
|
||||
assertThat(first).isEqualTo(ImportResult.Imported(ImportCounts(3, 8, 2)))
|
||||
assertThat(legacyFile().exists()).isFalse()
|
||||
assertThat(archiveFile().exists()).isTrue()
|
||||
assertThat(importer.isDone.first()).isTrue()
|
||||
|
||||
val second = importer.runIfNeeded()
|
||||
|
||||
assertThat(second).isEqualTo(ImportResult.AlreadyDone)
|
||||
assertThat(taskRows()).hasSize(8)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anInterruptedImportResumesFromTheArchiveWithoutDoubling() = runBlocking {
|
||||
// The process dying between the commit and the flag write is the one gap
|
||||
// the DataStore flag cannot cover on its own. Because the rename happens
|
||||
// first and the import always replaces, the next run finds the archive and
|
||||
// redoes the same work rather than importing a second copy.
|
||||
fixture(legacyFile())
|
||||
importer.runIfNeeded()
|
||||
importer.clearCompletion()
|
||||
|
||||
val resumed = importer.runIfNeeded()
|
||||
|
||||
assertThat(resumed).isEqualTo(ImportResult.Imported(ImportCounts(3, 8, 2)))
|
||||
assertThat(taskRows()).hasSize(8)
|
||||
assertThat(db.taskLists().lists()).hasSize(3)
|
||||
assertThat(db.alarms().all()).hasSize(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun runIfNeededMarksItselfDoneWhenThereIsNoLegacyDatabase() = runBlocking {
|
||||
assertThat(importer.runIfNeeded()).isEqualTo(ImportResult.NothingToImport)
|
||||
assertThat(importer.isDone.first()).isTrue()
|
||||
assertThat(taskRows()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reimportFromTheArchiveReplacesRatherThanMerges() = runBlocking {
|
||||
fixture(legacyFile())
|
||||
importer.runIfNeeded()
|
||||
|
||||
val again = importer.reimportFromArchive()
|
||||
|
||||
assertThat(again).isEqualTo(ImportResult.Imported(ImportCounts(3, 8, 2)))
|
||||
assertThat(db.taskLists().lists()).hasSize(3)
|
||||
assertThat(taskRows()).hasSize(8)
|
||||
assertThat(db.alarms().all()).hasSize(2)
|
||||
assertThat(archiveFile().exists()).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun replacingTwiceFromTheSameFileLeavesOneCopy() {
|
||||
importer.importFrom(fixture())
|
||||
val counts = importer.importFrom(fixture(temp.newFile("second.db")), replaceExisting = true)
|
||||
|
||||
assertThat(counts).isEqualTo(ImportCounts(3, 8, 2))
|
||||
assertThat(taskRows()).hasSize(8)
|
||||
assertThat(db.taskLists().lists()).hasSize(3)
|
||||
assertThat(db.alarms().all()).hasSize(2)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val FIXTURE = "tasks-v23.db"
|
||||
const val T0 = 1_768_467_600_000L
|
||||
const val DAY = 86_400_000L
|
||||
var counter = 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
|
||||
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.TaskQuery
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import org.junit.After
|
||||
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.Instant
|
||||
|
||||
/**
|
||||
* The seam over Room, exercised through [de.jeanlucmakiola.agendula.data.tasks
|
||||
* .TasksDataSource] rather than the DAOs — recurrence expansion and override
|
||||
* forking only exist at this level.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class RoomTasksDataSourceTest {
|
||||
|
||||
private lateinit var db: TasksDatabase
|
||||
private lateinit var source: RoomTasksDataSource
|
||||
private var listId = 0L
|
||||
|
||||
/** Truncated to the store's granularity: instants are columns of epoch millis. */
|
||||
private val now get() = Instant.fromEpochMilliseconds(Clock.System.now().toEpochMilliseconds())
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
db = Room.inMemoryDatabaseBuilder(
|
||||
ApplicationProvider.getApplicationContext(),
|
||||
TasksDatabase::class.java,
|
||||
).allowMainThreadQueries().build()
|
||||
source = RoomTasksDataSource(db)
|
||||
listId = source.createLocalList("Personal", 0xFF112233.toInt())
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = db.close()
|
||||
|
||||
private fun form(
|
||||
title: String = "task",
|
||||
due: Instant? = null,
|
||||
percentComplete: Int? = null,
|
||||
) = TaskForm(title = title, listId = listId, due = due, percentComplete = percentComplete)
|
||||
|
||||
/** Turns [taskId] into a weekly series anchored at [anchor]. */
|
||||
private fun makeRecurring(taskId: Long, anchor: Instant, rule: String = "FREQ=WEEKLY") {
|
||||
val entity = db.tasks().entity(taskId)!!
|
||||
db.tasks().update(entity.copy(dtstart = anchor, due = anchor + 1.days, rrule = rule))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createsAndReadsBackALocalList() {
|
||||
val lists = source.taskLists()
|
||||
|
||||
assertThat(lists).hasSize(1)
|
||||
assertThat(lists.single().name).isEqualTo("Personal")
|
||||
// No account, so the list still has to report something the lists screen
|
||||
// can group under.
|
||||
assertThat(lists.single().isLocal).isTrue()
|
||||
assertThat(lists.single().accountName).isEqualTo("Local")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun renamesAndRecoloursAList() {
|
||||
source.updateList(listId, " Errands ", 0xFF445566.toInt())
|
||||
|
||||
val list = source.taskLists().single()
|
||||
assertThat(list.name).isEqualTo("Errands")
|
||||
assertThat(list.color).isEqualTo(0xFF445566.toInt())
|
||||
// Nothing to sync a device-only list to, so the edit leaves it clean.
|
||||
assertThat(db.taskLists().entity(listId)!!.isDirty).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletingAListTakesItsTasksWithIt() {
|
||||
source.insertTask(form(title = "Buy milk"))
|
||||
source.insertTask(form(title = "Call the bank"))
|
||||
val other = source.createLocalList("Work", 0xFF778899.toInt())
|
||||
val keeper = source.insertTask(TaskForm(title = "Ship it", listId = other))
|
||||
|
||||
source.deleteList(listId)
|
||||
|
||||
assertThat(source.taskLists().map { it.id }).containsExactly(other)
|
||||
assertThat(source.tasks(TaskQuery(includeCompleted = true)).map { it.taskId })
|
||||
.containsExactly(keeper)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createsAndReadsBackANonRecurringTask() {
|
||||
val due = now + 1.days
|
||||
val id = source.insertTask(form(title = "Buy milk", due = due))
|
||||
|
||||
val task = source.task(id)!!
|
||||
|
||||
assertThat(task.taskId).isEqualTo(id)
|
||||
assertThat(task.title).isEqualTo("Buy milk")
|
||||
assertThat(task.due).isEqualTo(due)
|
||||
assertThat(task.isRecurring).isFalse()
|
||||
// A task that does not recur has no occurrence anchor, so it keys and edits
|
||||
// by task id exactly as it did against the provider.
|
||||
assertThat(task.occurrenceStart).isNull()
|
||||
assertThat(task.occurrenceKey).isEqualTo("$id")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mintsAUidForEveryTask() {
|
||||
val id = source.insertTask(form())
|
||||
|
||||
assertThat(db.tasks().entity(id)!!.uid).isNotEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun expandsARecurringSeriesIntoManyOccurrences() {
|
||||
val anchor = now
|
||||
val id = source.insertTask(form(title = "Water the plants"))
|
||||
makeRecurring(id, anchor)
|
||||
|
||||
val occurrences = source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id }
|
||||
|
||||
// The provider materialised exactly one upcoming occurrence; we expand the
|
||||
// whole window, so a weekly series yields well over a hundred.
|
||||
assertThat(occurrences.size).isGreaterThan(100)
|
||||
assertThat(occurrences.map { it.occurrenceStart }).containsNoDuplicates()
|
||||
assertThat(occurrences.map { it.occurrenceKey }).containsNoDuplicates()
|
||||
assertThat(occurrences.all { it.isRecurring }).isTrue()
|
||||
// Each occurrence keeps the series' length rather than the master's dates.
|
||||
val first = occurrences.minBy { it.occurrenceStart!! }
|
||||
assertThat(first.due!! - first.start!!).isEqualTo(1.days)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun exactlyOneOccurrenceIsTheCurrentOne() {
|
||||
val id = source.insertTask(form())
|
||||
makeRecurring(id, now - 30.days)
|
||||
|
||||
val occurrences = source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id }
|
||||
|
||||
assertThat(occurrences.count { it.distanceFromCurrent == 0 }).isEqualTo(1)
|
||||
assertThat(source.task(id)!!.distanceFromCurrent).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun editingOneOccurrenceForksARecurrenceIdOverride() {
|
||||
val anchor = now
|
||||
val id = source.insertTask(form(title = "Water the plants"))
|
||||
makeRecurring(id, anchor)
|
||||
val target = source.tasks(TaskQuery(listId = listId))
|
||||
.filter { it.taskId == id }
|
||||
.first { it.distanceFromCurrent == 1 }
|
||||
|
||||
source.updateInstance(id, target.occurrenceStart!!, form(title = "Water them twice"))
|
||||
|
||||
val override = db.tasks().override(id, target.occurrenceStart)!!
|
||||
// RFC 5545's model: the override shares its master's UID — that is what
|
||||
// makes it an override rather than a separate task. The dmfs provider
|
||||
// detached the occurrence into a new task with its own UID instead.
|
||||
assertThat(override.uid).isEqualTo(db.tasks().entity(id)!!.uid)
|
||||
assertThat(override.masterId).isEqualTo(id)
|
||||
assertThat(override.recurrenceId).isEqualTo(target.occurrenceStart)
|
||||
assertThat(override.rrule).isNull()
|
||||
assertThat(override.title).isEqualTo("Water them twice")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completingOneOccurrenceLeavesTheRestOfTheSeriesOpen() {
|
||||
val id = source.insertTask(form(title = "Water the plants"))
|
||||
makeRecurring(id, now)
|
||||
val open = { source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id } }
|
||||
val before = open()
|
||||
val target = before.first { it.distanceFromCurrent == 0 }
|
||||
|
||||
source.setCompletedInstance(id, target.occurrenceStart!!, completed = true)
|
||||
|
||||
// Writing the status onto the master would close the series: the master is
|
||||
// the row the task query filters on, so every occurrence would vanish.
|
||||
val after = open()
|
||||
assertThat(after).hasSize(before.size - 1)
|
||||
assertThat(after.map { it.occurrenceStart }).doesNotContain(target.occurrenceStart)
|
||||
assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||
|
||||
val override = db.tasks().override(id, target.occurrenceStart)!!
|
||||
assertThat(override.uid).isEqualTo(db.tasks().entity(id)!!.uid)
|
||||
assertThat(override.status).isEqualTo(TaskStatus.COMPLETED)
|
||||
assertThat(override.rrule).isNull()
|
||||
// The override stands for *that* occurrence, so it carries the
|
||||
// occurrence's resolved times, not the master's anchor.
|
||||
assertThat(override.dtstart).isEqualTo(target.occurrenceStart)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reopeningACompletedOccurrenceReusesItsOverride() {
|
||||
val id = source.insertTask(form(title = "Water the plants"))
|
||||
makeRecurring(id, now)
|
||||
val target = source.tasks(TaskQuery(listId = listId))
|
||||
.first { it.taskId == id && it.distanceFromCurrent == 0 }
|
||||
|
||||
source.setCompletedInstance(id, target.occurrenceStart!!, completed = true)
|
||||
source.setCompletedInstance(id, target.occurrenceStart, completed = false)
|
||||
|
||||
assertThat(db.tasks().overrides(id)).hasSize(1)
|
||||
assertThat(db.tasks().override(id, target.occurrenceStart)!!.status)
|
||||
.isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||
assertThat(source.tasks(TaskQuery(listId = listId)).map { it.occurrenceStart })
|
||||
.contains(target.occurrenceStart)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completingANonRecurringTaskThroughTheInstancePathWritesTheRowItself() {
|
||||
val id = source.insertTask(form(title = "Buy milk", due = now + 1.days))
|
||||
|
||||
source.setCompletedInstance(id, now, completed = true)
|
||||
|
||||
assertThat(db.tasks().overrides(id)).isEmpty()
|
||||
assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.COMPLETED)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anOverrideReplacesOnlyItsOwnOccurrence() {
|
||||
val id = source.insertTask(form(title = "Water the plants"))
|
||||
makeRecurring(id, now)
|
||||
// The list holds this series alone, so no filter is needed — and none can
|
||||
// be written on taskId, since the override reports its own row id.
|
||||
val before = source.tasks(TaskQuery(listId = listId))
|
||||
val target = before.first { it.distanceFromCurrent == 1 }
|
||||
|
||||
source.updateInstance(id, target.occurrenceStart!!, form(title = "Water them twice"))
|
||||
|
||||
val after = source.tasks(TaskQuery(listId = listId))
|
||||
assertThat(after).hasSize(before.size)
|
||||
val edited = after.single { it.title == "Water them twice" }
|
||||
assertThat(edited.occurrenceStart).isEqualTo(target.occurrenceStart)
|
||||
assertThat(after.filter { it.occurrenceStart == target.occurrenceStart }).hasSize(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* An edited occurrence addresses its own row, not the master's. That is what
|
||||
* sends the *next* edit down `updateTask` rather than forking a second time:
|
||||
* an override carries no rule, so it reads back as non-recurring.
|
||||
*/
|
||||
@Test
|
||||
fun anEditedOccurrenceReportsTheOverridesOwnId() {
|
||||
val id = source.insertTask(form(title = "Water the plants"))
|
||||
makeRecurring(id, now)
|
||||
val target = source.tasks(TaskQuery(listId = listId)).first { it.distanceFromCurrent == 1 }
|
||||
|
||||
source.updateInstance(id, target.occurrenceStart!!, form(title = "Water them twice"))
|
||||
|
||||
val edited = source.tasks(TaskQuery(listId = listId)).single { it.title == "Water them twice" }
|
||||
val overrideId = db.tasks().override(id, target.occurrenceStart)!!.id
|
||||
assertThat(edited.taskId).isEqualTo(overrideId)
|
||||
assertThat(edited.taskId).isNotEqualTo(id)
|
||||
assertThat(source.task(overrideId)!!.isRecurring).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun editingASeriesDoesNotReAnchorItWhenOneOccurrenceIsEdited() {
|
||||
val anchor = now
|
||||
val id = source.insertTask(form())
|
||||
makeRecurring(id, anchor)
|
||||
val target = source.tasks(TaskQuery(listId = listId))
|
||||
.filter { it.taskId == id }
|
||||
.first { it.distanceFromCurrent == 2 }
|
||||
|
||||
source.updateInstance(id, target.occurrenceStart!!, form(due = now + 99.days))
|
||||
|
||||
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(anchor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updatingANonRecurringTaskWritesThroughToItsRow() {
|
||||
val id = source.insertTask(form(title = "old"))
|
||||
|
||||
source.updateTask(id, form(title = "new"))
|
||||
|
||||
assertThat(source.task(id)!!.title).isEqualTo("new")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completionTogglesTheWholeTriple() {
|
||||
val id = source.insertTask(form())
|
||||
|
||||
source.setCompleted(id, completed = true)
|
||||
val done = db.tasks().entity(id)!!
|
||||
assertThat(done.status).isEqualTo(TaskStatus.COMPLETED)
|
||||
assertThat(done.percentComplete).isEqualTo(100)
|
||||
assertThat(done.completedAt).isNotNull()
|
||||
|
||||
source.setCompleted(id, completed = false)
|
||||
assertThat(db.tasks().entity(id)!!.completedAt).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completedTasksAreExcludedUnlessAskedFor() {
|
||||
val id = source.insertTask(form())
|
||||
source.setCompleted(id, completed = true)
|
||||
|
||||
assertThat(source.tasks(TaskQuery(listId = listId, includeCompleted = false))).isEmpty()
|
||||
assertThat(source.tasks(TaskQuery(listId = listId, includeCompleted = true))).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun alarmsRoundTripAndReplaceRatherThanAccumulate() {
|
||||
val id = source.insertTask(form(due = now + 1.days))
|
||||
|
||||
source.setAlarm(id, 30)
|
||||
assertThat(source.alarms()[id]).isEqualTo(30)
|
||||
|
||||
source.setAlarm(id, 60)
|
||||
assertThat(db.alarms().forTask(id)).hasSize(1)
|
||||
assertThat(source.alarms()[id]).isEqualTo(60)
|
||||
|
||||
source.setAlarm(id, null)
|
||||
assertThat(source.alarms()).doesNotContainKey(id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun forkingAnOccurrenceCarriesTheReminderOntoIt() {
|
||||
val id = source.insertTask(form(due = now + 1.days))
|
||||
makeRecurring(id, now)
|
||||
source.setAlarm(id, 30)
|
||||
val target = source.tasks(TaskQuery(listId = listId))
|
||||
.filter { it.taskId == id }
|
||||
.first { it.distanceFromCurrent == 1 }
|
||||
|
||||
source.updateInstance(id, target.occurrenceStart!!, form())
|
||||
|
||||
val override = db.tasks().override(id, target.occurrenceStart)!!
|
||||
assertThat(db.alarms().forTask(override.id).single().minutesBefore).isEqualTo(30)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletingATaskInALocalListRemovesItOutright() {
|
||||
val id = source.insertTask(form())
|
||||
|
||||
source.deleteTask(id)
|
||||
|
||||
// No account knows about it, so there is nothing to tombstone for.
|
||||
assertThat(db.tasks().entity(id)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletingASeriesTakesItsOverridesWithIt() {
|
||||
val id = source.insertTask(form())
|
||||
makeRecurring(id, now)
|
||||
val target = source.tasks(TaskQuery(listId = listId))
|
||||
.filter { it.taskId == id }
|
||||
.first { it.distanceFromCurrent == 1 }
|
||||
source.updateInstance(id, target.occurrenceStart!!, form(title = "moved"))
|
||||
|
||||
source.deleteTask(id)
|
||||
|
||||
assertThat(db.tasks().allOverrides(listId)).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun subtasksReadBackUnderTheirParent() {
|
||||
val parent = source.insertTask(form(title = "Prepare invoice"))
|
||||
val child = source.insertTask(form(title = "Gather receipts").copy(parentId = parent))
|
||||
|
||||
assertThat(source.subtasks(parent).map { it.taskId }).containsExactly(child)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun exportReadsMastersNotOccurrences() {
|
||||
val id = source.insertTask(form(title = "Water the plants"))
|
||||
makeRecurring(id, now)
|
||||
|
||||
val exported = source.exportTasks(listId)
|
||||
|
||||
// One row carrying the rule, not one row per occurrence with the rule lost.
|
||||
assertThat(exported).hasSize(1)
|
||||
assertThat(exported.single().rrule).isEqualTo("FREQ=WEEKLY")
|
||||
assertThat(exported.single().uid).isNotEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun insertingIntoAMissingListFails() {
|
||||
val thrown = runCatching { source.insertTask(form().copy(listId = 9_999)) }.exceptionOrNull()
|
||||
|
||||
assertThat(thrown).isNotNull()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
|
||||
import androidx.room.testing.MigrationTestHelper
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* The migration harness, proven against the committed schema in `app/schemas/`.
|
||||
*
|
||||
* There is one schema version today, so all there is to assert is that the helper
|
||||
* can build v1 from the exported JSON, seed it, and validate it back — i.e. the
|
||||
* export, the assets wiring and the identity hash all line up. That is the point:
|
||||
* the first real migration only has to add its own case.
|
||||
*
|
||||
* **Adding a v1 → v2 case.** When sync adds columns, bump [TasksDatabase]'s
|
||||
* `version`, let KSP export `2.json`, declare the `Migration(1, 2)` next to the
|
||||
* database, and add a test here shaped like this:
|
||||
*
|
||||
* ```
|
||||
* helper.createDatabase(TEST_DB, 1).use { db ->
|
||||
* db.execSQL("INSERT INTO task_lists (name, color) VALUES ('Groceries', 0)")
|
||||
* }
|
||||
* helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2).use { db ->
|
||||
* // read the seeded rows back — validation proves the shape, not the data
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TasksDatabaseMigrationTest {
|
||||
|
||||
@get:Rule
|
||||
val helper = MigrationTestHelper(
|
||||
InstrumentationRegistry.getInstrumentation(),
|
||||
TasksDatabase::class.java,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun buildsV1FromTheExportedSchema() {
|
||||
helper.createDatabase(TEST_DB, 1).use { db ->
|
||||
db.execSQL("INSERT INTO task_lists (id, name, color) VALUES (1, 'Groceries', 0)")
|
||||
db.execSQL("INSERT INTO tasks (id, list_id, uid, title) VALUES (1, 1, 'uid-1', 'Buy milk')")
|
||||
|
||||
db.query("SELECT title FROM tasks").use { cursor ->
|
||||
assertThat(cursor.moveToFirst()).isTrue()
|
||||
assertThat(cursor.getString(0)).isEqualTo("Buy milk")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun validatesV1AgainstTheExportedSchema() {
|
||||
helper.createDatabase(TEST_DB, 1).close()
|
||||
|
||||
// No migrations to run: v1 is opened and checked against 1.json, which is
|
||||
// what proves the harness rather than the schema.
|
||||
helper.runMigrationsAndValidate(TEST_DB, 1, true).use { db ->
|
||||
assertThat(db.version).isEqualTo(1)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TEST_DB = "migration-test.db"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
|
||||
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.TaskQuery
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.measureTime
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
/**
|
||||
* The plan's shape at scale: 5,000 tasks with 20 recurring series, read the way a
|
||||
* smart list reads them — one `tasks(TaskQuery(includeCompleted = true))`, which
|
||||
* includes expanding every series in memory.
|
||||
*
|
||||
* The assertion is a deliberately loose ceiling, so it catches a real regression
|
||||
* rather than CI jitter; the printed numbers are what the check is actually for.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TasksDatabasePerformanceTest {
|
||||
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
|
||||
private lateinit var db: TasksDatabase
|
||||
private lateinit var source: RoomTasksDataSource
|
||||
private var listId = 0L
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
delete()
|
||||
db = Room.databaseBuilder(context, TasksDatabase::class.java, DB)
|
||||
.allowMainThreadQueries()
|
||||
.build()
|
||||
source = RoomTasksDataSource(db)
|
||||
listId = source.createLocalList("Everything", 0xFF112233.toInt())
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
db.close()
|
||||
delete()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readsFiveThousandTasksWithTwentySeriesInsideTheBudget() {
|
||||
val seeded = measureTime { seed() }
|
||||
|
||||
// Discard the first read: it pays for statement compilation and page cache
|
||||
// warming, which a running app has already paid.
|
||||
source.tasks(TaskQuery(includeCompleted = true))
|
||||
val (tasks, elapsed) = measureTimedValue {
|
||||
source.tasks(TaskQuery(includeCompleted = true))
|
||||
}
|
||||
|
||||
println(
|
||||
"[perf] $TASK_COUNT tasks / $SERIES_COUNT series -> ${tasks.size} occurrences " +
|
||||
"in $elapsed (seed $seeded)",
|
||||
)
|
||||
// Expansion is bounded twice over: the read window is 1 year back and 2
|
||||
// forward, and each series stops at ExpansionWindow.maxOccurrences (500),
|
||||
// so the occurrence count cannot grow with the age of the series.
|
||||
assertThat(tasks.size).isAtLeast(TASK_COUNT)
|
||||
assertThat(elapsed.inWholeMilliseconds).isLessThan(CEILING_MILLIS)
|
||||
}
|
||||
|
||||
private fun seed() {
|
||||
val anchor = Clock.System.now() - 30.days
|
||||
val ids = ArrayList<Long>(TASK_COUNT)
|
||||
db.runInTransaction {
|
||||
repeat(TASK_COUNT) { index ->
|
||||
ids += source.insertTask(
|
||||
TaskForm(title = "Task $index", listId = listId, due = anchor + index.days),
|
||||
)
|
||||
}
|
||||
}
|
||||
db.runInTransaction {
|
||||
ids.take(SERIES_COUNT).forEach { id ->
|
||||
val entity = db.tasks().entity(id)!!
|
||||
db.tasks().update(
|
||||
entity.copy(dtstart = anchor, due = anchor + 1.days, rrule = "FREQ=DAILY"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun delete() {
|
||||
val base = context.getDatabasePath(DB)
|
||||
base.delete()
|
||||
listOf("-wal", "-shm").forEach { File(base.path + it).delete() }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DB = "performance-test.db"
|
||||
const val TASK_COUNT = 5_000
|
||||
const val SERIES_COUNT = 20
|
||||
const val CEILING_MILLIS = 8_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
|
||||
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.TaskQuery
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* The Auto Backup restore path, on disk.
|
||||
*
|
||||
* Auto Backup copies database files without checkpointing, and Room runs in WAL
|
||||
* mode — so `.db` alone can be a *stale* copy of a database whose recent writes
|
||||
* are still in the `-wal` sidecar. `res/xml/backup_rules.xml` carries all three
|
||||
* files and [DatabaseCheckpoint] truncates the log on `ON_STOP`; this asserts
|
||||
* that both of those actually do what they claim, and that neither alone is an
|
||||
* assumption.
|
||||
*
|
||||
* A file copy of a live database stands in for the backup transport — the
|
||||
* transport is what Auto Backup does to these files, and it is not what is under
|
||||
* test here.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TasksDatabaseRestoreTest {
|
||||
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
|
||||
private lateinit var db: TasksDatabase
|
||||
private lateinit var source: RoomTasksDataSource
|
||||
private var listId = 0L
|
||||
private var restored: TasksDatabase? = null
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
delete(LIVE)
|
||||
delete(BACKUP)
|
||||
db = open(LIVE)
|
||||
source = RoomTasksDataSource(db)
|
||||
listId = source.createLocalList("Personal", 0xFF112233.toInt())
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
restored?.close()
|
||||
db.close()
|
||||
delete(LIVE)
|
||||
delete(BACKUP)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun roomRunsInWalMode() {
|
||||
// Everything below is only interesting because of this.
|
||||
assertThat(journalMode()).isEqualTo("wal")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aBackupOfTheDbFileAloneLosesWhateverIsStillInTheWal() {
|
||||
write("checkpointed")
|
||||
checkpoint()
|
||||
write("only in the wal")
|
||||
|
||||
backUp(withSidecars = false)
|
||||
|
||||
assertThat(restore()).containsExactly("checkpointed")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aBackupThatCarriesTheSidecarsKeepsTheLastWrite() {
|
||||
write("checkpointed")
|
||||
checkpoint()
|
||||
write("only in the wal")
|
||||
|
||||
backUp(withSidecars = true)
|
||||
|
||||
assertThat(restore()).containsExactly("checkpointed", "only in the wal")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkpointingFirstMakesTheDbFileAloneEnough() {
|
||||
write("checkpointed")
|
||||
checkpoint()
|
||||
write("last write")
|
||||
|
||||
// What DatabaseCheckpoint runs on ON_STOP — the fallback for a restore
|
||||
// that arrives without the sidecars.
|
||||
checkpoint()
|
||||
backUp(withSidecars = false)
|
||||
|
||||
assertThat(restore()).containsExactly("checkpointed", "last write")
|
||||
}
|
||||
|
||||
// --- the moving parts -----------------------------------------------------
|
||||
|
||||
private fun open(name: String): TasksDatabase =
|
||||
Room.databaseBuilder(context, TasksDatabase::class.java, name)
|
||||
.allowMainThreadQueries()
|
||||
.build()
|
||||
|
||||
private fun write(title: String) {
|
||||
source.insertTask(TaskForm(title = title, listId = listId))
|
||||
}
|
||||
|
||||
private fun journalMode(): String =
|
||||
db.openHelper.writableDatabase.query("PRAGMA journal_mode").use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
cursor.getString(0).lowercase()
|
||||
}
|
||||
|
||||
/** [DatabaseCheckpoint]'s pragma, asserting it was not blocked by a reader. */
|
||||
private fun checkpoint() {
|
||||
db.openHelper.writableDatabase.query("PRAGMA wal_checkpoint(TRUNCATE)").use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
assertThat(cursor.getInt(0)).isEqualTo(0)
|
||||
}
|
||||
}
|
||||
|
||||
/** Copies the live database the way Auto Backup would: no checkpoint, files as they lie. */
|
||||
private fun backUp(withSidecars: Boolean) {
|
||||
delete(BACKUP)
|
||||
val live = context.getDatabasePath(LIVE)
|
||||
val backup = context.getDatabasePath(BACKUP)
|
||||
live.copyTo(backup, overwrite = true)
|
||||
if (!withSidecars) return
|
||||
SIDECARS.forEach { suffix ->
|
||||
val from = File(live.path + suffix)
|
||||
if (from.exists()) from.copyTo(File(backup.path + suffix), overwrite = true)
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens the copy as a fresh install would and reports the task titles that survived. */
|
||||
private fun restore(): List<String> {
|
||||
restored?.close()
|
||||
val database = open(BACKUP).also { restored = it }
|
||||
return RoomTasksDataSource(database).tasks(TaskQuery(includeCompleted = true)).map { it.title }
|
||||
}
|
||||
|
||||
private fun delete(name: String) {
|
||||
val base = context.getDatabasePath(name)
|
||||
base.delete()
|
||||
SIDECARS.forEach { File(base.path + it).delete() }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val LIVE = "restore-live.db"
|
||||
const val BACKUP = "restore-backup.db"
|
||||
val SIDECARS = listOf("-wal", "-shm")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
|
||||
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.domain.TaskStatus
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The schema, exercised through the DAOs. Instrumented rather than JVM because
|
||||
* the app's unit tests are plain JUnit 5 with no Robolectric, and Room needs a
|
||||
* real SQLite.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TasksDatabaseTest {
|
||||
|
||||
private lateinit var db: TasksDatabase
|
||||
private lateinit var lists: TaskListDao
|
||||
private lateinit var tasks: TaskDao
|
||||
private lateinit var alarms: TaskAlarmDao
|
||||
private lateinit var accounts: AccountDao
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
db = Room.inMemoryDatabaseBuilder(
|
||||
ApplicationProvider.getApplicationContext(),
|
||||
TasksDatabase::class.java,
|
||||
).allowMainThreadQueries().build()
|
||||
lists = db.taskLists()
|
||||
tasks = db.tasks()
|
||||
alarms = db.alarms()
|
||||
accounts = db.accounts()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = db.close()
|
||||
|
||||
private fun newList(name: String = "Groceries", accountId: Long? = null): Long =
|
||||
lists.insert(TaskListEntity(name = name, color = 0xFF00FF00.toInt(), accountId = accountId))
|
||||
|
||||
private fun newTask(
|
||||
listId: Long,
|
||||
uid: String = "uid-${counter++}",
|
||||
title: String? = "Buy milk",
|
||||
status: TaskStatus = TaskStatus.NEEDS_ACTION,
|
||||
parentId: Long? = null,
|
||||
masterId: Long? = null,
|
||||
recurrenceId: Instant? = null,
|
||||
): Long = tasks.insert(
|
||||
TaskEntity(
|
||||
listId = listId,
|
||||
uid = uid,
|
||||
title = title,
|
||||
status = status,
|
||||
parentId = parentId,
|
||||
masterId = masterId,
|
||||
recurrenceId = recurrenceId,
|
||||
),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun writesAndReadsAListWithItsTasks() {
|
||||
val accountId = accounts.insert(AccountEntity(displayName = "Fastmail"))
|
||||
val listId = newList(accountId = accountId)
|
||||
val due = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
||||
val taskId = tasks.insert(
|
||||
TaskEntity(
|
||||
listId = listId,
|
||||
uid = "uid-1",
|
||||
title = "Buy milk",
|
||||
description = "2%",
|
||||
due = due,
|
||||
priority = 3,
|
||||
status = TaskStatus.IN_PROCESS,
|
||||
percentComplete = 40,
|
||||
),
|
||||
)
|
||||
|
||||
val list = lists.lists().single()
|
||||
assertThat(list.list.id).isEqualTo(listId)
|
||||
assertThat(list.list.name).isEqualTo("Groceries")
|
||||
assertThat(list.accountDisplayName).isEqualTo("Fastmail")
|
||||
|
||||
val row = tasks.task(taskId)!!
|
||||
assertThat(row.task.title).isEqualTo("Buy milk")
|
||||
assertThat(row.task.due).isEqualTo(due)
|
||||
// Stored raw: an off-bucket PRIORITY must come back as it went in.
|
||||
assertThat(row.task.priority).isEqualTo(3)
|
||||
assertThat(row.task.status).isEqualTo(TaskStatus.IN_PROCESS)
|
||||
assertThat(row.task.percentComplete).isEqualTo(40)
|
||||
assertThat(row.listName).isEqualTo("Groceries")
|
||||
assertThat(row.accountDisplayName).isEqualTo("Fastmail")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readsTasksOfOneListAndHidesClosedOnesUnlessAsked() {
|
||||
val a = newList("A")
|
||||
val b = newList("B")
|
||||
newTask(a, title = "open")
|
||||
newTask(a, title = "done", status = TaskStatus.COMPLETED)
|
||||
newTask(a, title = "cancelled", status = TaskStatus.CANCELLED)
|
||||
newTask(b, title = "elsewhere")
|
||||
|
||||
assertThat(tasks.tasks(a, includeCompleted = false).map { it.task.title })
|
||||
.containsExactly("open")
|
||||
assertThat(tasks.tasks(a, includeCompleted = true)).hasSize(3)
|
||||
assertThat(tasks.tasks(null, includeCompleted = true)).hasSize(4)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readsSubtasksByParent() {
|
||||
val listId = newList()
|
||||
val parent = newTask(listId, title = "parent")
|
||||
newTask(listId, title = "child", parentId = parent)
|
||||
|
||||
assertThat(tasks.subtasks(parent).map { it.task.title }).containsExactly("child")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hidesTombstonesFromReadsAndExports() {
|
||||
val listId = newList()
|
||||
val taskId = newTask(listId)
|
||||
tasks.markDeleted(taskId, Instant.fromEpochMilliseconds(1))
|
||||
|
||||
assertThat(tasks.tasks(listId, includeCompleted = true)).isEmpty()
|
||||
assertThat(tasks.task(taskId)).isNull()
|
||||
assertThat(tasks.exportTasks(listId)).isEmpty()
|
||||
assertThat(tasks.entity(taskId)).isNotNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keepsOverridesOutOfTheMasterReads() {
|
||||
val listId = newList()
|
||||
val master = newTask(listId, uid = "series")
|
||||
val override = newTask(
|
||||
listId,
|
||||
uid = "series",
|
||||
masterId = master,
|
||||
recurrenceId = Instant.fromEpochMilliseconds(5_000),
|
||||
)
|
||||
|
||||
assertThat(tasks.tasks(listId, includeCompleted = true).map { it.task.id })
|
||||
.containsExactly(master)
|
||||
assertThat(tasks.overrides(master).map { it.id }).containsExactly(override)
|
||||
assertThat(tasks.allOverrides(listId).map { it.id }).containsExactly(override)
|
||||
assertThat(tasks.override(master, Instant.fromEpochMilliseconds(5_000))?.id)
|
||||
.isEqualTo(override)
|
||||
assertThat(tasks.exportTasks(listId).map { it.id }).containsExactly(master)
|
||||
}
|
||||
|
||||
// --- cascades -------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun deletingAListDeletesItsTasks() {
|
||||
val listId = newList()
|
||||
val taskId = newTask(listId)
|
||||
|
||||
lists.delete(listId)
|
||||
|
||||
assertThat(tasks.entity(taskId)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletingASeriesDeletesItsOverrides() {
|
||||
val listId = newList()
|
||||
val master = newTask(listId, uid = "series")
|
||||
val override = newTask(
|
||||
listId,
|
||||
uid = "series",
|
||||
masterId = master,
|
||||
recurrenceId = Instant.fromEpochMilliseconds(5_000),
|
||||
)
|
||||
|
||||
tasks.delete(master)
|
||||
|
||||
assertThat(tasks.entity(override)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletingAParentPromotesItsSubtasks() {
|
||||
val listId = newList()
|
||||
val parent = newTask(listId, title = "parent")
|
||||
val child = newTask(listId, title = "child", parentId = parent)
|
||||
|
||||
tasks.delete(parent)
|
||||
|
||||
val promoted = tasks.entity(child)
|
||||
assertThat(promoted).isNotNull()
|
||||
assertThat(promoted!!.parentId).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletingATaskDeletesItsAlarms() {
|
||||
val listId = newList()
|
||||
val taskId = newTask(listId)
|
||||
alarms.replaceForTask(taskId, TaskAlarmEntity(taskId = taskId, minutesBefore = 15))
|
||||
assertThat(alarms.all()).hasSize(1)
|
||||
|
||||
tasks.delete(taskId)
|
||||
|
||||
assertThat(alarms.all()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletingAnAccountDetachesItsListsInsteadOfDeletingThem() {
|
||||
val accountId = accounts.insert(AccountEntity(displayName = "Fastmail"))
|
||||
val listId = newList(accountId = accountId)
|
||||
|
||||
accounts.delete(accountId)
|
||||
|
||||
assertThat(lists.entity(listId)!!.accountId).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun replacingAnAlarmLeavesOnlyTheNewOne() {
|
||||
val listId = newList()
|
||||
val taskId = newTask(listId)
|
||||
alarms.replaceForTask(taskId, TaskAlarmEntity(taskId = taskId, minutesBefore = 15))
|
||||
alarms.replaceForTask(taskId, TaskAlarmEntity(taskId = taskId, minutesBefore = 30))
|
||||
|
||||
assertThat(alarms.forTask(taskId).map { it.minutesBefore }).containsExactly(30)
|
||||
assertThat(alarms.forTask(taskId).single().reference).isEqualTo(AlarmReference.DUE)
|
||||
|
||||
alarms.replaceForTask(taskId, null)
|
||||
assertThat(alarms.forTask(taskId)).isEmpty()
|
||||
}
|
||||
|
||||
// --- the unique index -----------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun anOverrideMayShareItsMastersUid() {
|
||||
val listId = newList()
|
||||
val master = newTask(listId, uid = "series")
|
||||
newTask(listId, uid = "series", masterId = master, recurrenceId = Instant.fromEpochMilliseconds(1))
|
||||
newTask(listId, uid = "series", masterId = master, recurrenceId = Instant.fromEpochMilliseconds(2))
|
||||
|
||||
assertThat(tasks.overrides(master)).hasSize(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsTwoOverridesOfTheSameOccurrence() {
|
||||
val listId = newList()
|
||||
val master = newTask(listId, uid = "series")
|
||||
val at = Instant.fromEpochMilliseconds(1)
|
||||
newTask(listId, uid = "series", masterId = master, recurrenceId = at)
|
||||
|
||||
val failure = runCatching {
|
||||
newTask(listId, uid = "series", masterId = master, recurrenceId = at)
|
||||
}.exceptionOrNull()
|
||||
|
||||
assertThat(failure).isNotNull()
|
||||
assertThat(failure!!.message).contains("UNIQUE")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theSameUidMayExistInAnotherList() {
|
||||
val a = newList("A")
|
||||
val b = newList("B")
|
||||
newTask(a, uid = "shared")
|
||||
newTask(b, uid = "shared")
|
||||
|
||||
assertThat(tasks.byUid(a, "shared")).isNotNull()
|
||||
assertThat(tasks.byUid(b, "shared")).isNotNull()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
var counter = 0
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,15 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- Tasks provider access. Both permission sets are declared; the active one
|
||||
<!-- External tasks-provider access, for StorageMode.EXTERNAL only. Both
|
||||
permission sets are declared, since the manifest is static; the active one
|
||||
(org.tasks.* for tasks.org, org.dmfs.* for OpenTasks) is requested at
|
||||
runtime by the permission flow. Both are dangerous-level. -->
|
||||
runtime by the permission flow, and only once the user has actually
|
||||
selected External mode. Both are dangerous-level.
|
||||
|
||||
StorageMode.OWN needs nothing here: it is a Room database in our own data
|
||||
directory. Agendula publishes no ContentProvider and declares no
|
||||
permissions of its own. -->
|
||||
<uses-permission android:name="org.dmfs.permission.READ_TASKS" />
|
||||
<uses-permission android:name="org.dmfs.permission.WRITE_TASKS" />
|
||||
<uses-permission android:name="org.tasks.permission.READ_TASKS" />
|
||||
@@ -74,8 +80,11 @@
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- Re-sync reminders when the provider changes (external DAVx5 sync).
|
||||
Targets both known authorities; the host must be static. -->
|
||||
<!-- Re-sync reminders when an external provider changes — DAVx5 pulling
|
||||
tasks while Agendula is backgrounded. External mode only: in OWN mode
|
||||
nothing outside the app can change our data, and Room's
|
||||
InvalidationTracker covers our own writes. An intent-filter host must
|
||||
be a literal, so both external authorities are listed. -->
|
||||
<receiver
|
||||
android:name=".data.reminders.ProviderChangeReceiver"
|
||||
android:exported="true">
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
package de.jeanlucmakiola.agendula
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
||||
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||
import de.jeanlucmakiola.agendula.data.tasks.StartupGate
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.DatabaseCheckpoint
|
||||
import de.jeanlucmakiola.floret.crash.CrashConfig
|
||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* Application entry point. Registered as android:name=".AgendulaApp". Besides
|
||||
@@ -35,17 +39,42 @@ class AgendulaApp : Application() {
|
||||
issueTitle = getString(R.string.crash_report_issue_title),
|
||||
),
|
||||
)
|
||||
val scheduler = EntryPointAccessors
|
||||
.fromApplication(this, ReminderEntryPoint::class.java)
|
||||
.reminderScheduler()
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
|
||||
runCatching { scheduler.sync() }
|
||||
val entryPoint = EntryPointAccessors.fromApplication(this, AppEntryPoint::class.java)
|
||||
val scheduler = entryPoint.reminderScheduler()
|
||||
// Mirror the stored storage mode into ProviderResolver and import a
|
||||
// v0.3.x install's tasks, both before anything reads a store.
|
||||
val startupGate = entryPoint.startupGate()
|
||||
val scope = entryPoint.applicationScope()
|
||||
// An alarm is armed off whichever store was active when it was scheduled,
|
||||
// so a switch has to rebuild the set. Armed only once startup's own
|
||||
// null -> stored transition is past, which the launch sync below covers.
|
||||
val started = AtomicBoolean(false)
|
||||
entryPoint.providerResolver().onModeChanged {
|
||||
if (started.get()) scope.launch { runCatching { scheduler.sync() } }
|
||||
}
|
||||
startupGate.start()
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(entryPoint.databaseCheckpoint())
|
||||
scope.launch {
|
||||
// Wait for the stored mode and the import to land first. Rescheduling
|
||||
// alarms against whichever store autoMode happens to pick would arm
|
||||
// them off the wrong one — or off an empty one, mid-import.
|
||||
runCatching {
|
||||
startupGate.awaitReady()
|
||||
started.set(true)
|
||||
scheduler.sync()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface ReminderEntryPoint {
|
||||
interface AppEntryPoint {
|
||||
fun reminderScheduler(): ReminderScheduler
|
||||
fun startupGate(): StartupGate
|
||||
fun providerResolver(): ProviderResolver
|
||||
|
||||
@ApplicationScope
|
||||
fun applicationScope(): CoroutineScope
|
||||
fun databaseCheckpoint(): DatabaseCheckpoint
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ class DemoSeeder @Inject constructor(
|
||||
repository.createTask(TaskForm(title = "Sketch the Agendula app icon", listId = listId))
|
||||
|
||||
val done = repository.createTask(TaskForm(title = "Renew domain name", listId = listId, due = at(ts - 2 * day)))
|
||||
repository.setCompleted(done, completed = true)
|
||||
repository.setCompleted(done, occurrenceStart = null, completed = true)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
@@ -3,6 +3,8 @@ package de.jeanlucmakiola.agendula.data.di
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
@@ -10,12 +12,21 @@ import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.jeanlucmakiola.agendula.data.tasks.AndroidProviderEnvironment
|
||||
import de.jeanlucmakiola.agendula.data.tasks.AndroidTasksDataSource
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ModeRoutingTasksDataSource
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderEnvironment
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepositoryImpl
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import javax.inject.Provider
|
||||
import javax.inject.Singleton
|
||||
|
||||
private val Context.agendulaDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||
@@ -28,11 +39,11 @@ abstract class DataBindModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindTasksDataSource(impl: AndroidTasksDataSource): TasksDataSource
|
||||
abstract fun bindTasksRepository(impl: TasksRepositoryImpl): TasksRepository
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindTasksRepository(impl: TasksRepositoryImpl): TasksRepository
|
||||
abstract fun bindProviderEnvironment(impl: AndroidProviderEnvironment): ProviderEnvironment
|
||||
}
|
||||
|
||||
@Module
|
||||
@@ -44,7 +55,41 @@ object DataProvideModule {
|
||||
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
||||
context.agendulaDataStore
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideTasksDatabase(@ApplicationContext context: Context): TasksDatabase =
|
||||
Room.databaseBuilder(context, TasksDatabase::class.java, TasksDatabase.NAME)
|
||||
// Room's default, stated rather than assumed: Auto Backup copies files
|
||||
// without checkpointing, so a `-wal` sidecar can hold writes the
|
||||
// backed-up `.db` does not. The backup rules carry all three files and
|
||||
// the app checkpoints on ON_STOP.
|
||||
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
|
||||
.build()
|
||||
|
||||
/**
|
||||
* The active store, chosen by [StorageMode].
|
||||
*
|
||||
* Resolved per injection point rather than bound once, because the mode is a
|
||||
* user setting that [de.jeanlucmakiola.agendula.data.tasks.StorageModeHolder]
|
||||
* can change while the process lives. Both implementations are singletons, so
|
||||
* this picks between two long-lived objects rather than building either.
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideTasksDataSource(
|
||||
resolver: ProviderResolver,
|
||||
room: Provider<RoomTasksDataSource>,
|
||||
external: Provider<AndroidTasksDataSource>,
|
||||
): TasksDataSource = ModeRoutingTasksDataSource(resolver, room, external)
|
||||
|
||||
@Provides
|
||||
@IoDispatcher
|
||||
fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@ApplicationScope
|
||||
fun provideApplicationScope(): CoroutineScope =
|
||||
// SupervisorJob so one failing collector can't take the others down with it.
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
}
|
||||
|
||||
@@ -6,3 +6,13 @@ import javax.inject.Qualifier
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class IoDispatcher
|
||||
|
||||
/**
|
||||
* Marks the process-lifetime [kotlinx.coroutines.CoroutineScope] — for work that
|
||||
* outlives any screen and has nothing to be cancelled by, such as keeping the
|
||||
* selected storage mode mirrored out of DataStore. It is never cancelled, so
|
||||
* don't launch anything unbounded in it.
|
||||
*/
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class ApplicationScope
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package de.jeanlucmakiola.agendula.data.export
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.agendula.domain.export.ExportDocument
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.IOException
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** Where an export ended up, for the UI to report. */
|
||||
data class ExportResult(val fileCount: Int, val taskListNames: List<String>)
|
||||
|
||||
/**
|
||||
* Why an export failed, as a value rather than a message: the UI ships in eleven
|
||||
* locales, so the wording has to come from a string resource.
|
||||
*/
|
||||
enum class ExportFailure {
|
||||
FOLDER_UNAVAILABLE,
|
||||
FOLDER_NOT_WRITABLE,
|
||||
CANNOT_CREATE_FILE,
|
||||
LOST_ACCESS,
|
||||
WRITE_FAILED,
|
||||
}
|
||||
|
||||
/** The export could not be written. */
|
||||
class ExportFailedException(
|
||||
val failure: ExportFailure,
|
||||
cause: Throwable? = null,
|
||||
) : IOException(failure.name, cause)
|
||||
|
||||
/**
|
||||
* Writes [ExportDocument]s to a user-chosen location through the Storage Access
|
||||
* Framework.
|
||||
*
|
||||
* No storage permission anywhere: SAF hands us a `Uri` the user picked
|
||||
* themselves, which is both the modern approach and the only one that still works
|
||||
* on scoped storage. The caller owns launching `ACTION_CREATE_DOCUMENT` (for
|
||||
* [writeZip]) or `ACTION_OPEN_DOCUMENT_TREE` (for [writeToTree]) and passes the
|
||||
* result here.
|
||||
*
|
||||
* Marked in `docs/STORAGE-AND-SYNC.md` as floret-kit material — the plumbing is
|
||||
* not task-domain and Calendula will want the same thing. Kept app-local for now
|
||||
* on the kit's own stated principle of not extracting until a second consumer
|
||||
* actually exists; the seam is here, so moving it later is a file move.
|
||||
*/
|
||||
@Singleton
|
||||
class ExportWriter @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Writes every document into [treeUri], a directory the user picked.
|
||||
*
|
||||
* A same-named file is truncated and rewritten in place rather than deleted
|
||||
* and recreated: SAF would otherwise append " (1)" and turn the folder into
|
||||
* an unusable pile of snapshots, and a delete that is not followed by a
|
||||
* successful create loses the previous export outright.
|
||||
*
|
||||
* The directory is listed once. `DocumentFile.findFile` queries the whole
|
||||
* tree per call, so looking each name up in the loop is one full
|
||||
* cross-process directory scan per list.
|
||||
*/
|
||||
suspend fun writeToTree(treeUri: Uri, documents: List<ExportDocument>): ExportResult =
|
||||
withContext(io) {
|
||||
runCatching {
|
||||
val tree = DocumentFile.fromTreeUri(context, treeUri)
|
||||
?: throw ExportFailedException(ExportFailure.FOLDER_UNAVAILABLE)
|
||||
if (!tree.canWrite()) throw ExportFailedException(ExportFailure.FOLDER_NOT_WRITABLE)
|
||||
val existing = tree.listFiles().associateBy { it.name }
|
||||
|
||||
documents.forEach { document ->
|
||||
val file = existing[document.fileName]
|
||||
?: tree.createFile(MIME_ICALENDAR, document.fileName)
|
||||
?: throw ExportFailedException(ExportFailure.CANNOT_CREATE_FILE)
|
||||
write(file.uri, document.content)
|
||||
}
|
||||
}.getOrElse { throw asExportFailure(it) }
|
||||
ExportResult(documents.size, documents.map { it.fileName })
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes every document into a single zip at [target].
|
||||
*
|
||||
* The one-file form, for sharing or for a backup the user filed somewhere
|
||||
* themselves — one attachment rather than one per list.
|
||||
*/
|
||||
suspend fun writeZip(target: Uri, documents: List<ExportDocument>): ExportResult =
|
||||
withContext(io) {
|
||||
runCatching {
|
||||
context.contentResolver.openOutputStream(target, "wt")?.use { raw ->
|
||||
ZipOutputStream(raw.buffered()).use { zip ->
|
||||
documents.forEach { document ->
|
||||
zip.putNextEntry(ZipEntry(document.fileName))
|
||||
zip.write(document.content)
|
||||
zip.closeEntry()
|
||||
}
|
||||
}
|
||||
} ?: throw ExportFailedException(ExportFailure.WRITE_FAILED)
|
||||
}.getOrElse { throw asExportFailure(it) }
|
||||
ExportResult(documents.size, documents.map { it.fileName })
|
||||
}
|
||||
|
||||
private fun write(target: Uri, bytes: ByteArray) {
|
||||
runCatching {
|
||||
// "wt" truncates. Without it a shorter export leaves the tail of the
|
||||
// previous, longer one behind and produces a corrupt file.
|
||||
context.contentResolver.openOutputStream(target, "wt")?.use { it.write(bytes) }
|
||||
?: throw ExportFailedException(ExportFailure.WRITE_FAILED)
|
||||
}.getOrElse { throw asExportFailure(it) }
|
||||
}
|
||||
|
||||
private fun asExportFailure(cause: Throwable): Throwable = when (cause) {
|
||||
is ExportFailedException -> cause
|
||||
// A SAF grant can be revoked between the picker and the write (the volume
|
||||
// was unmounted, the provider's process died, the user cleared the grant).
|
||||
is SecurityException -> ExportFailedException(ExportFailure.LOST_ACCESS, cause)
|
||||
is IOException -> ExportFailedException(ExportFailure.WRITE_FAILED, cause)
|
||||
else -> cause
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MIME_ICALENDAR = "text/calendar"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package de.jeanlucmakiola.agendula.data.export
|
||||
|
||||
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||
import de.jeanlucmakiola.agendula.domain.export.ExportDocument
|
||||
import de.jeanlucmakiola.agendula.domain.export.ExportList
|
||||
import de.jeanlucmakiola.agendula.domain.export.ICalendarWriter
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Turns the user's task lists into `.ics` documents.
|
||||
*
|
||||
* Export is a v1 feature rather than a nicety because of where the data now
|
||||
* lives: our own provider is inside the app's private storage, so in Local mode a
|
||||
* user's tasks exist in exactly one place and uninstalling deletes them. On Play,
|
||||
* where most people will never have a sync engine, that is the majority case.
|
||||
*
|
||||
* **One document per list**, because a list is a CalDAV collection and that is the
|
||||
* unit every other client understands. Bundling everything into a single file
|
||||
* would flatten the lists away, and list membership is not recoverable from a
|
||||
* VTODO afterwards.
|
||||
*/
|
||||
@Singleton
|
||||
class TaskExporter @Inject constructor(
|
||||
private val dataSource: TasksDataSource,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Serialises [listIds] — every visible list when null.
|
||||
*
|
||||
* A list with no tasks still produces a document. An empty `.ics` is a real
|
||||
* answer ("this list is empty"), whereas a missing file is indistinguishable
|
||||
* from the export having gone wrong.
|
||||
*/
|
||||
suspend fun export(listIds: Set<Long>? = null): List<ExportDocument> = withContext(io) {
|
||||
dataSource.taskLists()
|
||||
.filter { listIds == null || it.id in listIds }
|
||||
.map { list ->
|
||||
val document = ExportList(
|
||||
listId = list.id,
|
||||
name = list.name,
|
||||
accountName = list.accountName,
|
||||
tasks = dataSource.exportTasks(list.id),
|
||||
)
|
||||
ExportDocument(
|
||||
fileName = fileNameFor(list.name, list.id),
|
||||
content = ICalendarWriter.write(document).toByteArray(Charsets.UTF_8),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
/**
|
||||
* A file name derived from the list name, safe on every filesystem the
|
||||
* user might pick through SAF (including FAT32 on an SD card).
|
||||
*
|
||||
* The list id is appended rather than trusted to be redundant: two lists on
|
||||
* different accounts may share a name, and two exports landing on the same
|
||||
* file would silently lose one of them.
|
||||
*/
|
||||
fun fileNameFor(listName: String, listId: Long): String {
|
||||
val safe = listName
|
||||
.map { if (it.isLetterOrDigit() || it == '-' || it == '_') it else '-' }
|
||||
.joinToString("")
|
||||
.trim('-')
|
||||
.take(60)
|
||||
.ifBlank { "list" }
|
||||
return "$safe-$listId.ics"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,12 @@ import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec
|
||||
import de.jeanlucmakiola.floret.reminders.applyReminderOverride
|
||||
import de.jeanlucmakiola.floret.reminders.reminderLeadFor
|
||||
import de.jeanlucmakiola.floret.reminders.reminderLeadsFor
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
@@ -39,15 +40,17 @@ data class Settings(
|
||||
val bottomAddBar: Boolean = false,
|
||||
/**
|
||||
* Per-list overrides of [reminderLeadMinutes]: a list present in the map
|
||||
* overrides the global default (a null value = no reminder); absent = inherit.
|
||||
* overrides the global default (an empty list = no reminder); absent =
|
||||
* inherit. Agendula offers a single reminder, so each override is a
|
||||
* one-element (or empty) list.
|
||||
*/
|
||||
val perListReminderOverride: Map<Long, Int?> = emptyMap(),
|
||||
val perListReminderOverride: Map<Long, List<Int>> = emptyMap(),
|
||||
/** Optional edit-form fields shown by default; the rest sit behind "More fields". */
|
||||
val defaultEditFields: Set<TaskFormField> = emptySet(),
|
||||
) {
|
||||
/** The lead time for a task in [listId]: its override if set, else the global default. */
|
||||
fun reminderLeadFor(listId: Long): Int? =
|
||||
perListReminderOverride.reminderLeadFor(listId, reminderLeadMinutes)
|
||||
perListReminderOverride.reminderLeadsFor(listId, listOf(reminderLeadMinutes)).firstOrNull()
|
||||
}
|
||||
|
||||
/** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */
|
||||
@@ -80,6 +83,31 @@ class SettingsPrefs @Inject constructor(
|
||||
|
||||
suspend fun setReminderLeadMinutes(minutes: Int) = dataStore.edit { it[REMINDER_LEAD] = minutes }
|
||||
|
||||
/**
|
||||
* Which task store backs the app, or `null` while the user has not chosen —
|
||||
* which is the normal state, since most people never open Settings.
|
||||
*
|
||||
* Kept out of [Settings] on purpose. Everything in there is a rendering
|
||||
* preference collected by the UI; this one selects an authority in the data
|
||||
* layer, is read on paths that must not wait for a whole settings object, and
|
||||
* `null` genuinely means "undecided" rather than "default" — the difference
|
||||
* matters, because undecided is what lets `ProviderResolver.autoMode` keep an
|
||||
* upgrading Posture A user pointed at the provider that holds their data.
|
||||
*/
|
||||
val storageMode: Flow<StorageMode?> = dataStore.data.map { p ->
|
||||
when (val stored = p[STORAGE_MODE]) {
|
||||
null -> null
|
||||
// 0.3.x's value for the bundled dmfs provider. That store is gone and
|
||||
// its data was imported into OWN, so read it as OWN rather than
|
||||
// letting it fall through to autoMode — someone who chose local
|
||||
// storage explicitly would otherwise be sent to an external provider.
|
||||
"LOCAL" -> StorageMode.OWN
|
||||
else -> runCatching { StorageMode.valueOf(stored) }.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setStorageMode(mode: StorageMode) = dataStore.edit { it[STORAGE_MODE] = mode.name }
|
||||
|
||||
/** One-time reminder onboarding gate; false until the step has been shown. */
|
||||
val reminderOnboardingDone: Flow<Boolean> = dataStore.data.map { it[REMINDER_ONBOARDING_DONE] ?: false }
|
||||
|
||||
@@ -111,6 +139,7 @@ class SettingsPrefs @Inject constructor(
|
||||
val SHOW_ADD_SUBTASK_ROW = booleanPreferencesKey("show_add_subtask_row")
|
||||
val BOTTOM_ADD_BAR = booleanPreferencesKey("bottom_add_bar")
|
||||
val REMINDER_ONBOARDING_DONE = booleanPreferencesKey("reminder_onboarding_done")
|
||||
val STORAGE_MODE = stringPreferencesKey("storage_mode")
|
||||
val LIST_REMINDER_OVERRIDE = stringPreferencesKey("list_reminder_override")
|
||||
val DEFAULT_EDIT_FIELDS = stringSetPreferencesKey("default_edit_fields")
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.agendula.data.reminders
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.net.toUri
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||
@@ -45,7 +46,14 @@ class DueReminderReceiver : BroadcastReceiver() {
|
||||
companion object {
|
||||
private const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID"
|
||||
|
||||
fun intent(context: Context, taskId: Long): Intent =
|
||||
Intent(context, DueReminderReceiver::class.java).putExtra(EXTRA_TASK_ID, taskId)
|
||||
/**
|
||||
* [triggerAt] rides in the intent *data*, not just an extra: PendingIntent
|
||||
* identity ignores extras, so two occurrences of the same recurring task
|
||||
* would otherwise collapse into one alarm under FLAG_UPDATE_CURRENT.
|
||||
*/
|
||||
fun intent(context: Context, taskId: Long, triggerAt: Long): Intent =
|
||||
Intent(context, DueReminderReceiver::class.java)
|
||||
.setData("agendula://reminder/$taskId/$triggerAt".toUri())
|
||||
.putExtra(EXTRA_TASK_ID, taskId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ package de.jeanlucmakiola.agendula.data.reminders
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.SystemClock
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -20,10 +22,25 @@ import javax.inject.Inject
|
||||
class ProviderChangeReceiver : BroadcastReceiver() {
|
||||
|
||||
@Inject lateinit var scheduler: ReminderScheduler
|
||||
@Inject lateinit var providerResolver: ProviderResolver
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
// The receiver has to stay exported to hear the provider's broadcast, and
|
||||
// the sender holds no permission we could require — so validate the
|
||||
// broadcast itself. Without this, any installed app can spam a full
|
||||
// re-sync (an unbounded provider read) by firing a matching intent.
|
||||
if (intent.action != Intent.ACTION_PROVIDER_CHANGED) return
|
||||
val authority = providerResolver.resolve()?.authority ?: return
|
||||
if (intent.data?.host != authority) return
|
||||
// External sync can fire these in bursts; one re-sync per burst is plenty.
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
synchronized(Companion) {
|
||||
if (now - lastSyncAt < MIN_SYNC_INTERVAL_MS) return
|
||||
lastSyncAt = now
|
||||
}
|
||||
|
||||
val pending = goAsync()
|
||||
scope.launch {
|
||||
try {
|
||||
@@ -33,4 +50,11 @@ class ProviderChangeReceiver : BroadcastReceiver() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MIN_SYNC_INTERVAL_MS = 10_000L
|
||||
|
||||
@Volatile
|
||||
var lastSyncAt = -MIN_SYNC_INTERVAL_MS
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,15 +12,18 @@ import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The self-scheduled due-reminder engine. Tasks providers don't deliver
|
||||
* reminders, so Agendula reads upcoming due tasks and arms one exact [AlarmManager]
|
||||
* alarm each, within a rolling window. Re-run on app start, boot and provider
|
||||
* change; it diffs against [ScheduledReminderStore] so only changed alarms move.
|
||||
* The self-scheduled due-reminder engine. Nothing else delivers task reminders —
|
||||
* not the platform, not a tasks provider — so Agendula reads upcoming due tasks
|
||||
* and arms one exact [AlarmManager] alarm each, within a rolling window. Re-run
|
||||
* on app start, on boot, on a store switch, and on an external provider change;
|
||||
* it diffs against [ScheduledReminderStore] so only changed alarms move.
|
||||
*/
|
||||
@Singleton
|
||||
class ReminderScheduler @Inject constructor(
|
||||
@@ -31,47 +34,89 @@ class ReminderScheduler @Inject constructor(
|
||||
private val providerResolver: ProviderResolver,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) {
|
||||
suspend fun sync() = withContext(io) {
|
||||
val provider = providerResolver.resolve()
|
||||
private val syncLock = Mutex()
|
||||
|
||||
/**
|
||||
* Diff the armed alarms against the store and move only what changed.
|
||||
*
|
||||
* Serialised: the diff is a read-modify-write over [ScheduledReminderStore],
|
||||
* and callers overlap (a store switch fires this while the launch sync may
|
||||
* still be running). Two interleaved runs would each write their own set as
|
||||
* the whole truth, leaving the other's alarms armed but unrecorded — never
|
||||
* cancelled, and firing against the wrong store's task ids.
|
||||
*/
|
||||
suspend fun sync() = withContext(io) { syncLock.withLock { syncLocked() } }
|
||||
|
||||
private suspend fun syncLocked() {
|
||||
val settings = settingsPrefs.settings.first()
|
||||
if (provider == null || !providerResolver.hasPermission(provider) || !settings.remindersEnabled) {
|
||||
// Gate on whether the store is readable, not on whether a provider
|
||||
// resolves: our own store deliberately resolves to no provider, so the
|
||||
// latter clears every reminder in the default mode.
|
||||
if (!settings.remindersEnabled || !providerResolver.canReadStore()) {
|
||||
clearAll()
|
||||
return@withContext
|
||||
return
|
||||
}
|
||||
val now = System.currentTimeMillis()
|
||||
val horizon = now + WINDOW_MS
|
||||
val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) }
|
||||
.getOrElse { return@withContext }
|
||||
.getOrElse { return }
|
||||
|
||||
// One reminder per *occurrence*: a recurring series yields a row per
|
||||
// occurrence, all sharing a taskId, so this is a Set rather than a
|
||||
// taskId-keyed Map — keying by task would collapse a daily recurring task
|
||||
// down to one arbitrary reminder.
|
||||
// Per-task leads. One query for all of them.
|
||||
val perTask = runCatching { dataSource.alarms() }.getOrElse { emptyMap() }
|
||||
|
||||
val desired = tasks
|
||||
.filter { !it.isClosed && it.due != null }
|
||||
.mapNotNull { task ->
|
||||
// The task's list may override the global lead, or opt out entirely
|
||||
// (override = null), in which case it gets no reminder at all.
|
||||
val lead = settings.reminderLeadFor(task.listId) ?: return@mapNotNull null
|
||||
task.taskId to (task.due!!.toEpochMilliseconds() - lead.coerceAtLeast(0) * 60_000L)
|
||||
// A reminder set on the task itself wins; otherwise the task's list
|
||||
// may override the global lead, or opt out entirely (override =
|
||||
// null), in which case it gets no reminder at all.
|
||||
val reminder = perTask[task.taskId]
|
||||
val lead = reminder?.minutesBefore
|
||||
?: settings.reminderLeadFor(task.listId)
|
||||
?: return@mapNotNull null
|
||||
// A stored reminder says what it counts back from. Ours are always
|
||||
// before due, but an imported dmfs alarm or another client's can be
|
||||
// before *start* — firing those off the due date is silently wrong
|
||||
// for every task whose start and due differ.
|
||||
val anchor = if (reminder?.fromStart == true) task.start ?: task.due!! else task.due!!
|
||||
ScheduledReminder(
|
||||
taskId = task.taskId,
|
||||
triggerAt = anchor.toEpochMilliseconds() - lead.coerceAtLeast(0) * 60_000L,
|
||||
)
|
||||
}
|
||||
.toMap()
|
||||
.filterValues { it in now..horizon }
|
||||
// The lower bound trails `now` so a reminder missed while the device was
|
||||
// off still fires once on boot instead of being silently dropped —
|
||||
// setExactAndAllowWhileIdle delivers a past trigger immediately. Anything
|
||||
// already armed stays armed (the diff below), so it can't re-fire.
|
||||
.filter { it.triggerAt in (now - MISSED_GRACE_MS)..horizon }
|
||||
.toSet()
|
||||
|
||||
val previous = store.all()
|
||||
(previous.keys - desired.keys).forEach { cancel(it) }
|
||||
desired.forEach { (taskId, triggerAt) ->
|
||||
if (previous[taskId] != triggerAt) schedule(taskId, triggerAt)
|
||||
}
|
||||
(previous - desired).forEach { cancel(it) }
|
||||
(desired - previous).forEach { schedule(it) }
|
||||
store.replace(desired)
|
||||
}
|
||||
|
||||
private fun alarmManager(): AlarmManager = context.getSystemService(AlarmManager::class.java)
|
||||
|
||||
private fun pendingIntent(taskId: Long, create: Boolean): PendingIntent? {
|
||||
private fun pendingIntent(reminder: ScheduledReminder, create: Boolean): PendingIntent? {
|
||||
val flags = (if (create) PendingIntent.FLAG_UPDATE_CURRENT else PendingIntent.FLAG_NO_CREATE) or
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
return PendingIntent.getBroadcast(context, taskId.toInt(), DueReminderReceiver.intent(context, taskId), flags)
|
||||
return PendingIntent.getBroadcast(
|
||||
context,
|
||||
reminder.requestCode,
|
||||
DueReminderReceiver.intent(context, reminder.taskId, reminder.triggerAt),
|
||||
flags,
|
||||
)
|
||||
}
|
||||
|
||||
private fun schedule(taskId: Long, triggerAt: Long) {
|
||||
val pi = pendingIntent(taskId, create = true) ?: return
|
||||
private fun schedule(reminder: ScheduledReminder) {
|
||||
val triggerAt = reminder.triggerAt
|
||||
val pi = pendingIntent(reminder, create = true) ?: return
|
||||
val am = alarmManager()
|
||||
val canExact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms()
|
||||
if (canExact) {
|
||||
@@ -81,19 +126,21 @@ class ReminderScheduler @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancel(taskId: Long) {
|
||||
pendingIntent(taskId, create = false)?.let {
|
||||
private fun cancel(reminder: ScheduledReminder) {
|
||||
pendingIntent(reminder, create = false)?.let {
|
||||
alarmManager().cancel(it)
|
||||
it.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun clearAll() {
|
||||
store.all().keys.forEach { cancel(it) }
|
||||
store.replace(emptyMap())
|
||||
store.all().forEach { cancel(it) }
|
||||
store.replace(emptySet())
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val WINDOW_MS = 30L * 24 * 60 * 60 * 1000 // 30 days
|
||||
/** How long after its trigger a missed reminder is still worth firing. */
|
||||
const val MISSED_GRACE_MS = 6L * 60 * 60 * 1000 // 6 hours
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,25 +9,38 @@ import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Remembers which task reminders are currently scheduled (taskId → trigger time),
|
||||
* so [ReminderScheduler] can diff against a fresh computation and cancel only the
|
||||
* alarms that changed. Persisted in DataStore as a set of `taskId|trigger` strings.
|
||||
* One armed alarm. A recurring task has many occurrences sharing a [taskId], so
|
||||
* the trigger time is part of the identity — keying by task alone would collapse
|
||||
* a daily task down to a single reminder.
|
||||
*/
|
||||
data class ScheduledReminder(val taskId: Long, val triggerAt: Long) {
|
||||
/**
|
||||
* Request code for this alarm's PendingIntent. Derived from both fields so
|
||||
* sibling occurrences don't share (and overwrite) one alarm slot.
|
||||
*/
|
||||
val requestCode: Int get() = (taskId * 31 + triggerAt).hashCode()
|
||||
}
|
||||
|
||||
/**
|
||||
* Remembers which task reminders are currently armed, so [ReminderScheduler] can
|
||||
* diff against a fresh computation and touch only the alarms that changed.
|
||||
* Persisted in DataStore as a set of `taskId|trigger` strings.
|
||||
*/
|
||||
@Singleton
|
||||
class ScheduledReminderStore @Inject constructor(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) {
|
||||
suspend fun all(): Map<Long, Long> =
|
||||
suspend fun all(): Set<ScheduledReminder> =
|
||||
dataStore.data.first()[KEY].orEmpty().mapNotNull { entry ->
|
||||
val parts = entry.split('|')
|
||||
val id = parts.getOrNull(0)?.toLongOrNull()
|
||||
val at = parts.getOrNull(1)?.toLongOrNull()
|
||||
if (id != null && at != null) id to at else null
|
||||
}.toMap()
|
||||
if (id != null && at != null) ScheduledReminder(id, at) else null
|
||||
}.toSet()
|
||||
|
||||
suspend fun replace(scheduled: Map<Long, Long>) {
|
||||
suspend fun replace(scheduled: Set<ScheduledReminder>) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[KEY] = scheduled.entries.map { "${it.key}|${it.value}" }.toSet()
|
||||
prefs[KEY] = scheduled.map { "${it.taskId}|${it.triggerAt}" }.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,13 +11,16 @@ import android.os.Looper
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.agendula.domain.export.ExportTask
|
||||
import java.time.ZoneId
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The only class that knows about the ContentResolver, [TasksContract] and the
|
||||
@@ -83,6 +86,26 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
override fun exportTasks(listId: Long): List<ExportTask> {
|
||||
// projection = null for the same reason queryInstances uses it: the tasks
|
||||
// table's shape varies across provider versions, and the by-name mapper
|
||||
// reads what's there.
|
||||
val uri = TasksContract.tasksUri(authority())
|
||||
return resolver.query(
|
||||
uri,
|
||||
null,
|
||||
// _deleted marks a row awaiting a sync round-trip. It's gone as far as
|
||||
// the user is concerned, so exporting it would resurrect deleted tasks
|
||||
// in the backup.
|
||||
"${Tasks.LIST_ID} = ? AND (${Tasks.DELETED} IS NULL OR ${Tasks.DELETED} = 0)",
|
||||
arrayOf(listId.toString()),
|
||||
null,
|
||||
)?.use { c ->
|
||||
val reader = CursorColumnReader(c)
|
||||
buildList { while (c.moveToNext()) add(TaskMapper.exportTask(reader)) }
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
// --- writes ---------------------------------------------------------------
|
||||
|
||||
override fun insertTask(form: TaskForm): Long {
|
||||
@@ -98,12 +121,109 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
if (rows == 0) throw TaskWriteFailedException("update task $taskId")
|
||||
}
|
||||
|
||||
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) {
|
||||
val instanceId = instanceIdFor(taskId, occurrenceStart)
|
||||
?: throw TaskWriteFailedException("update instance $taskId@$occurrenceStart: no such occurrence")
|
||||
val values = TaskWriteMapper.instanceValues(form, ZoneId.systemDefault().id)
|
||||
val uri = TasksContract.instanceUri(authority(), instanceId)
|
||||
val rows = resolver.update(uri, values.toContentValues(), null, null)
|
||||
if (rows == 0) throw TaskWriteFailedException("update instance $instanceId")
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider's instance row id for one occurrence.
|
||||
*
|
||||
* The seam addresses occurrences by `(taskId, occurrenceStart)`; writing
|
||||
* through the instances URI still needs the row id, so it is looked up here
|
||||
* rather than carried around above the data layer. Selection is on `task_id`
|
||||
* only — the anchor is matched in Kotlin because the column that holds it
|
||||
* (`instance_original_time`) is missing on older provider schemas, where a
|
||||
* WHERE clause naming it would throw instead of falling back.
|
||||
*/
|
||||
private fun instanceIdFor(taskId: Long, occurrenceStart: Instant): Long? {
|
||||
val uri = TasksContract.instancesUri(authority())
|
||||
val selection = "${Instances.TASK_ID} = ?"
|
||||
return resolver.query(uri, null, selection, arrayOf(taskId.toString()), null)?.use { c ->
|
||||
val reader = CursorColumnReader(c)
|
||||
while (c.moveToNext()) {
|
||||
if (TaskMapper.occurrenceAnchor(reader) == occurrenceStart) {
|
||||
return@use reader.getLong(Tasks.ID)
|
||||
}
|
||||
}
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
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 — and delete+insert
|
||||
// means we never have to track property_id.
|
||||
resolver.delete(
|
||||
uri,
|
||||
"${Properties.TASK_ID} = ? AND ${Properties.MIMETYPE} = ?",
|
||||
arrayOf(taskId.toString(), TasksContract.Alarm.MIMETYPE),
|
||||
)
|
||||
if (minutesBeforeDue != null) {
|
||||
resolver.insert(uri, TaskWriteMapper.alarmValues(taskId, minutesBeforeDue).toContentValues())
|
||||
?: throw TaskWriteFailedException("set alarm for task $taskId")
|
||||
}
|
||||
}
|
||||
|
||||
override fun alarms(): Map<Long, TaskReminder> {
|
||||
val uri = TasksContract.propertiesUri(authority())
|
||||
val projection = arrayOf(
|
||||
Properties.TASK_ID,
|
||||
TasksContract.Alarm.MINUTES_BEFORE,
|
||||
TasksContract.Alarm.REFERENCE,
|
||||
)
|
||||
return resolver.query(
|
||||
uri,
|
||||
projection,
|
||||
"${Properties.MIMETYPE} = ?",
|
||||
arrayOf(TasksContract.Alarm.MIMETYPE),
|
||||
null,
|
||||
)?.use { c ->
|
||||
val reader = CursorColumnReader(c)
|
||||
buildMap {
|
||||
while (c.moveToNext()) {
|
||||
val id = reader.getLong(Properties.TASK_ID)
|
||||
val minutes = reader.getInt(TasksContract.Alarm.MINUTES_BEFORE)
|
||||
val reference = reader.getInt(TasksContract.Alarm.REFERENCE)
|
||||
if (id != null && minutes != null) {
|
||||
put(
|
||||
id,
|
||||
TaskReminder(
|
||||
minutesBefore = minutes,
|
||||
fromStart = reference == TasksContract.Alarm.REFERENCE_START,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} ?: emptyMap()
|
||||
}
|
||||
|
||||
override fun setCompleted(taskId: Long, completed: Boolean) {
|
||||
val values = TaskWriteMapper.completionValues(completed, System.currentTimeMillis())
|
||||
val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
|
||||
if (rows == 0) throw TaskWriteFailedException("complete task $taskId")
|
||||
}
|
||||
|
||||
/**
|
||||
* Through the instances URI, which is what makes the provider fork an override
|
||||
* rather than close the series. No instance row for the anchor means the task
|
||||
* is not a series after all — the plain write is then the right one.
|
||||
*/
|
||||
override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) {
|
||||
val instanceId = instanceIdFor(taskId, occurrenceStart)
|
||||
?: return setCompleted(taskId, completed)
|
||||
val values = TaskWriteMapper.completionValues(completed, System.currentTimeMillis())
|
||||
val uri = TasksContract.instanceUri(authority(), instanceId)
|
||||
val rows = resolver.update(uri, values.toContentValues(), null, null)
|
||||
if (rows == 0) throw TaskWriteFailedException("complete instance $instanceId")
|
||||
}
|
||||
|
||||
override fun deleteTask(taskId: Long) {
|
||||
resolver.delete(taskUri(authority(), taskId), null, null)
|
||||
}
|
||||
@@ -120,6 +240,31 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
return result.lastPathSegment?.toLongOrNull() ?: throw TaskWriteFailedException("create local list: no id")
|
||||
}
|
||||
|
||||
override fun updateList(listId: Long, name: String, color: Int) {
|
||||
val values = TaskWriteMapper.listValues(name, color)
|
||||
val rows = resolver.update(listSyncUri(listId), values.toContentValues(), null, null)
|
||||
if (rows == 0) throw TaskWriteFailedException("update list $listId")
|
||||
}
|
||||
|
||||
override fun deleteList(listId: Long) {
|
||||
val rows = resolver.delete(listSyncUri(listId), null, null)
|
||||
if (rows == 0) throw TaskWriteFailedException("delete list $listId")
|
||||
}
|
||||
|
||||
/**
|
||||
* A list row addressed as its own account's sync adapter — the provider only
|
||||
* lets that caller write the `tasklists` table, and the account has to be the
|
||||
* row's own (the params are matched against it, not merely accepted).
|
||||
*/
|
||||
private fun listSyncUri(listId: Long): Uri {
|
||||
val authority = authority()
|
||||
val uri = TasksContract.listUri(authority, listId)
|
||||
val account = resolver.query(uri, arrayOf(Lists.ACCOUNT_NAME, Lists.ACCOUNT_TYPE), null, null, null)
|
||||
?.use { c -> if (c.moveToFirst()) c.getString(0).orEmpty() to c.getString(1).orEmpty() else null }
|
||||
?: throw TaskWriteFailedException("list $listId not found")
|
||||
return TasksContract.asSyncAdapter(uri, account.first, account.second)
|
||||
}
|
||||
|
||||
// --- observation ----------------------------------------------------------
|
||||
|
||||
override fun registerObserver(onChange: () -> Unit): AutoCloseable {
|
||||
@@ -127,9 +272,16 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
val observer = object : ContentObserver(Handler(Looper.getMainLooper())) {
|
||||
override fun onChange(selfChange: Boolean) = onChange()
|
||||
}
|
||||
resolver.registerContentObserver(TasksContract.instancesUri(provider.authority), true, observer)
|
||||
resolver.registerContentObserver(TasksContract.listsUri(provider.authority), true, observer)
|
||||
return AutoCloseable { resolver.unregisterContentObserver(observer) }
|
||||
// Register both or neither: if the second call throws, the first
|
||||
// registration would otherwise leak (no AutoCloseable was handed back yet).
|
||||
try {
|
||||
resolver.registerContentObserver(TasksContract.instancesUri(provider.authority), true, observer)
|
||||
resolver.registerContentObserver(TasksContract.listsUri(provider.authority), true, observer)
|
||||
} catch (e: RuntimeException) {
|
||||
runCatching { resolver.unregisterContentObserver(observer) }
|
||||
throw e
|
||||
}
|
||||
return AutoCloseable { runCatching { resolver.unregisterContentObserver(observer) } }
|
||||
}
|
||||
|
||||
private fun Map<String, Any?>.toContentValues(): ContentValues {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.agendula.domain.export.ExportTask
|
||||
import javax.inject.Provider
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Routes every call to the store [StorageMode] selects.
|
||||
*
|
||||
* Per-call rather than bound once: the mode is a setting the user can change
|
||||
* while the process lives, and [StorageModeHolder] pushes the new value into
|
||||
* [ProviderResolver] without rebuilding the object graph. Both delegates are
|
||||
* singletons, so this chooses between two existing objects.
|
||||
*
|
||||
* Room versus a third-party ContentProvider — nothing else.
|
||||
*/
|
||||
class ModeRoutingTasksDataSource(
|
||||
private val resolver: ProviderResolver,
|
||||
private val room: Provider<RoomTasksDataSource>,
|
||||
private val external: Provider<AndroidTasksDataSource>,
|
||||
) : TasksDataSource {
|
||||
|
||||
private fun active(): TasksDataSource =
|
||||
when (resolver.mode()) {
|
||||
StorageMode.OWN -> room.get()
|
||||
StorageMode.EXTERNAL -> external.get()
|
||||
}
|
||||
|
||||
override fun taskLists(): List<TaskList> = active().taskLists()
|
||||
override fun tasks(query: TaskQuery): List<Task> = active().tasks(query)
|
||||
override fun task(taskId: Long): Task? = active().task(taskId)
|
||||
override fun subtasks(parentTaskId: Long): List<Task> = active().subtasks(parentTaskId)
|
||||
override fun insertTask(form: TaskForm): Long = active().insertTask(form)
|
||||
override fun updateTask(taskId: Long, form: TaskForm) = active().updateTask(taskId, form)
|
||||
|
||||
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) =
|
||||
active().updateInstance(taskId, occurrenceStart, form)
|
||||
|
||||
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) = active().setAlarm(taskId, minutesBeforeDue)
|
||||
override fun alarms(): Map<Long, TaskReminder> = active().alarms()
|
||||
override fun exportTasks(listId: Long): List<ExportTask> = active().exportTasks(listId)
|
||||
override fun setCompleted(taskId: Long, completed: Boolean) = active().setCompleted(taskId, completed)
|
||||
|
||||
override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) =
|
||||
active().setCompletedInstance(taskId, occurrenceStart, completed)
|
||||
|
||||
override fun deleteTask(taskId: Long) = active().deleteTask(taskId)
|
||||
override fun createLocalList(name: String, color: Int): Long = active().createLocalList(name, color)
|
||||
override fun updateList(listId: Long, name: String, color: Int) = active().updateList(listId, name, color)
|
||||
override fun deleteList(listId: Long) = active().deleteList(listId)
|
||||
/**
|
||||
* Unlike every other method here, an observer is registered once and then
|
||||
* *held* — so it cannot be routed per call, and would otherwise stay bound to
|
||||
* whichever store was active when the flow started. Switching stores in
|
||||
* Settings would then leave every open screen listening to the store it is no
|
||||
* longer reading from.
|
||||
*
|
||||
* So the registration moves with the mode, and the switch itself counts as a
|
||||
* change: the data underneath every live flow has just been replaced.
|
||||
*/
|
||||
override fun registerObserver(onChange: () -> Unit): AutoCloseable {
|
||||
val lock = Any()
|
||||
var closed = false
|
||||
var handle: AutoCloseable? = runCatching { active().registerObserver(onChange) }.getOrNull()
|
||||
|
||||
val modeHandle = resolver.onModeChanged {
|
||||
synchronized(lock) {
|
||||
if (!closed) {
|
||||
handle?.let { runCatching { it.close() } }
|
||||
handle = runCatching { active().registerObserver(onChange) }.getOrNull()
|
||||
}
|
||||
}
|
||||
onChange()
|
||||
}
|
||||
return AutoCloseable {
|
||||
synchronized(lock) {
|
||||
closed = true
|
||||
modeHandle.close()
|
||||
handle?.let { runCatching { it.close() } }
|
||||
handle = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The two platform facts [ProviderResolver] needs, behind an interface.
|
||||
*
|
||||
* Same seam the data source uses, for the same reason: which store a returning
|
||||
* user lands on is decided by [ProviderResolver.autoMode], getting it wrong shows
|
||||
* them an empty app, and that decision is worth testing on the JVM rather than
|
||||
* only on a device. Everything Android-shaped lives here so the logic above stays
|
||||
* plain Kotlin.
|
||||
*/
|
||||
interface ProviderEnvironment {
|
||||
|
||||
/** The package declaring [authority], or `null` when nothing on the device does. */
|
||||
fun packageDeclaring(authority: String): String?
|
||||
|
||||
/** Whether this app currently holds [permission]. */
|
||||
fun isGranted(permission: String): Boolean
|
||||
|
||||
/** [packageName]'s own app name, or null when it cannot be read. */
|
||||
fun appLabel(packageName: String): String?
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class AndroidProviderEnvironment @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : ProviderEnvironment {
|
||||
|
||||
override fun packageDeclaring(authority: String): String? =
|
||||
context.packageManager.resolveContentProvider(authority, 0)?.packageName
|
||||
|
||||
override fun isGranted(permission: String): Boolean =
|
||||
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
override fun appLabel(packageName: String): String? = runCatching {
|
||||
val pm = context.packageManager
|
||||
pm.getApplicationLabel(pm.getApplicationInfo(packageName, 0)).toString()
|
||||
}.getOrNull()
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.retryWhen
|
||||
|
||||
private const val BASE_RETRY_MS = 1_000L
|
||||
private const val MAX_RETRY_MS = 30_000L
|
||||
|
||||
/** 1s, 2s, 4s … capped at 30s, so a permanently-absent provider costs little. */
|
||||
private fun retryDelayMs(attempt: Long): Long =
|
||||
(BASE_RETRY_MS shl attempt.coerceAtMost(5).toInt()).coerceAtMost(MAX_RETRY_MS)
|
||||
|
||||
/**
|
||||
* Recover a provider-backed flow without killing it.
|
||||
*
|
||||
* Provider reads fail for reasons that resolve on their own: the read permission
|
||||
* isn't granted yet (first launch collects before the permission gate), or the
|
||||
* provider app is mid-update. A terminal `catch` swallows the failure *and*
|
||||
* cancels the upstream, so the flow never produces again — the screen stays empty
|
||||
* until the process restarts, even after the user grants the permission.
|
||||
*
|
||||
* This emits [fallback] instead and keeps retrying with a capped backoff, so the
|
||||
* collector recovers on its own once the provider becomes readable.
|
||||
*/
|
||||
fun <T> Flow<T>.recoveringFromProviderFailure(fallback: () -> T): Flow<T> =
|
||||
retryWhen { _, attempt ->
|
||||
emit(fallback())
|
||||
delay(retryDelayMs(attempt))
|
||||
true
|
||||
}
|
||||
@@ -1,15 +1,12 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* A tasks provider Agendula can talk to. The same dmfs `TaskProvider` backs every
|
||||
* candidate, so the [TasksContract] columns apply regardless of which is present.
|
||||
* An external tasks provider Agendula can talk to. Every candidate runs the same
|
||||
* dmfs `TaskProvider`, so the [TasksContract] columns apply to either.
|
||||
*/
|
||||
data class TaskProvider(
|
||||
val authority: String,
|
||||
@@ -19,41 +16,122 @@ data class TaskProvider(
|
||||
)
|
||||
|
||||
/**
|
||||
* The A/B seam. Detects which tasks provider is installed at runtime and which
|
||||
* permission set it needs, so nothing above the data layer hardcodes an
|
||||
* authority. Under Posture B (bundled provider) this simply finds our own
|
||||
* `org.dmfs.tasks` first. See docs/PLAN.md.
|
||||
* Discovers the *external* tasks providers (OpenTasks, tasks.org) that
|
||||
* [StorageMode.EXTERNAL] can be pointed at.
|
||||
*
|
||||
* This used to be the A/B seam between an external provider and one Agendula
|
||||
* bundled itself. That second half is gone: [StorageMode.OWN] is a Room database
|
||||
* with no authority, no ContentResolver and nothing to permit, so there is
|
||||
* nothing here for it to resolve. See `docs/OWN-STORE.md`.
|
||||
*
|
||||
* Which store is active comes from [storageMode]; how that gets decided when the
|
||||
* user has not chosen is [autoMode].
|
||||
*/
|
||||
@Singleton
|
||||
class ProviderResolver @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val environment: ProviderEnvironment,
|
||||
) {
|
||||
|
||||
/** The active provider, or `null` when no tasks provider is installed. */
|
||||
fun resolve(): TaskProvider? {
|
||||
for (candidate in CANDIDATES) {
|
||||
val info = context.packageManager.resolveContentProvider(candidate.authority, 0)
|
||||
?: continue
|
||||
return candidate.copy(packageName = info.packageName)
|
||||
private val modeListeners = CopyOnWriteArrayList<() -> Unit>()
|
||||
|
||||
/**
|
||||
* The user's explicit choice, or `null` while they have not made one (which is
|
||||
* the normal state — most people never open Settings). Kept as a plain field
|
||||
* rather than read from DataStore on demand because [resolve] is called from
|
||||
* synchronous data-source code on every query, including from the main thread
|
||||
* via `providerStatus()`. [StorageModeHolder] owns keeping it current.
|
||||
*
|
||||
* Assigning a *different* mode notifies [onModeChanged]: every live store
|
||||
* observer is bound to one store and has to be moved across.
|
||||
*/
|
||||
@Volatile
|
||||
var storageMode: StorageMode? = null
|
||||
set(value) {
|
||||
val changed = field != value
|
||||
field = value
|
||||
if (changed) modeListeners.forEach { it() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe switches between stores. Fires on the thread that set [storageMode]
|
||||
* — [StorageModeHolder]'s collector — so listeners must be cheap and must not
|
||||
* block.
|
||||
*/
|
||||
fun onModeChanged(listener: () -> Unit): AutoCloseable {
|
||||
modeListeners += listener
|
||||
return AutoCloseable { modeListeners -= listener }
|
||||
}
|
||||
|
||||
/** The active store, resolving the undecided case through [autoMode]. */
|
||||
fun mode(): StorageMode = storageMode ?: autoMode()
|
||||
|
||||
/**
|
||||
* The provider to query, or `null` — either because [StorageMode.OWN] is
|
||||
* active and there is no provider involved at all, or because
|
||||
* [StorageMode.EXTERNAL] is and none is installed. Callers that need to tell
|
||||
* those apart ask [mode].
|
||||
*/
|
||||
fun resolve(): TaskProvider? = when (mode()) {
|
||||
StorageMode.OWN -> null
|
||||
StorageMode.EXTERNAL -> resolveExternal()
|
||||
}
|
||||
|
||||
/**
|
||||
* What to use when the user has not chosen — and the one piece of real
|
||||
* judgement in this class, because getting it wrong loses people their data.
|
||||
*
|
||||
* Ranking our own store first unconditionally would be wrong: someone who
|
||||
* has been using Agendula over OpenTasks since 0.3.x would update, land on an
|
||||
* empty database, and reasonably conclude their tasks were deleted.
|
||||
*
|
||||
* So the tell is **whether we already hold an external provider's runtime
|
||||
* permission**. That is a dangerous permission — it can only be there because
|
||||
* a previous version asked and the user agreed, which is precisely the
|
||||
* definition of "this person is an existing Posture A user". A fresh install
|
||||
* never holds it, and gets our own store.
|
||||
*
|
||||
* Deliberately cheap and synchronous: a PackageManager lookup and a permission
|
||||
* check, no database probe. Settings overrides it either way.
|
||||
*/
|
||||
fun autoMode(): StorageMode {
|
||||
val external = resolveExternal()
|
||||
return if (external != null && hasPermission(external)) StorageMode.EXTERNAL else StorageMode.OWN
|
||||
}
|
||||
|
||||
/** The first installed external candidate, or `null` when none is present. */
|
||||
fun resolveExternal(): TaskProvider? {
|
||||
for (candidate in EXTERNAL_CANDIDATES) {
|
||||
val packageName = environment.packageDeclaring(candidate.authority) ?: continue
|
||||
return candidate.copy(packageName = packageName)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun hasPermission(provider: TaskProvider): Boolean =
|
||||
granted(provider.readPermission) && granted(provider.writePermission)
|
||||
environment.isGranted(provider.readPermission) && environment.isGranted(provider.writePermission)
|
||||
|
||||
private fun granted(permission: String): Boolean =
|
||||
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
||||
/**
|
||||
* Whether the active store can be read at all.
|
||||
*
|
||||
* [StorageMode.OWN] always can — it is our own database, with nothing to
|
||||
* install and nothing to grant. Only [StorageMode.EXTERNAL] can be
|
||||
* unreadable. Callers that gate on `resolve() != null` instead get this wrong
|
||||
* the moment OWN is active, because OWN resolves to no provider by design.
|
||||
*/
|
||||
fun canReadStore(): Boolean = when (mode()) {
|
||||
StorageMode.OWN -> true
|
||||
StorageMode.EXTERNAL -> resolveExternal()?.let(::hasPermission) == true
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Verified on-device: tasks.org exposes `org.tasks.opentasks` backed by
|
||||
* `org.dmfs.provider.tasks.TaskProvider`, guarded by
|
||||
* `org.tasks.permission.*` (dangerous). OpenTasks uses `org.dmfs.tasks`
|
||||
* + `org.dmfs.permission.*`. OpenTasks is listed first as the canonical
|
||||
* authority; on a device with only one installed, order is moot.
|
||||
* `org.dmfs.provider.tasks.TaskProvider`, guarded by `org.tasks.permission.*`
|
||||
* (dangerous). OpenTasks uses `org.dmfs.tasks` + `org.dmfs.permission.*`.
|
||||
* OpenTasks is listed first as the canonical authority; on a device with
|
||||
* only one installed, order is moot.
|
||||
*/
|
||||
val CANDIDATES: List<TaskProvider> = listOf(
|
||||
val EXTERNAL_CANDIDATES: List<TaskProvider> = listOf(
|
||||
TaskProvider(
|
||||
authority = "org.dmfs.tasks",
|
||||
readPermission = "org.dmfs.permission.READ_TASKS",
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
||||
import de.jeanlucmakiola.agendula.data.tasks.legacy.OneShotImport
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The work that has to finish before anything reads a task store: the stored
|
||||
* [StorageMode] has to reach [ProviderResolver], and a v0.3.x install's tasks
|
||||
* have to be imported out of the dmfs provider's file into Room.
|
||||
*
|
||||
* Both are startup races with the same shape. Reading before the mode lands
|
||||
* answers from `autoMode()` instead of the user's choice; reading before the
|
||||
* import lands shows an upgrading user an empty app, which is the single worst
|
||||
* thing this migration could do.
|
||||
*/
|
||||
@Singleton
|
||||
class StartupGate @Inject constructor(
|
||||
private val storageModeHolder: StorageModeHolder,
|
||||
private val oneShotImport: OneShotImport,
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
) {
|
||||
|
||||
private val ready = CompletableDeferred<Unit>()
|
||||
|
||||
/** Call once, from `Application.onCreate`. */
|
||||
fun start() {
|
||||
storageModeHolder.start()
|
||||
scope.launch {
|
||||
// Opens the gate even on failure: a store that cannot be imported is
|
||||
// still better shown empty than not shown at all, and the source file
|
||||
// is left where it was either way.
|
||||
runCatching {
|
||||
storageModeHolder.awaitReady()
|
||||
oneShotImport.runIfNeeded()
|
||||
}
|
||||
ready.complete(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun awaitReady() = ready.await()
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
/**
|
||||
* Which task store backs the app — the user's choice, per `docs/OWN-STORE.md`.
|
||||
*
|
||||
* Only two values, though `docs/STORAGE-AND-SYNC.md` describes three modes.
|
||||
* **Synced is not a third store**: it is [OWN] with an account attached to a
|
||||
* list, which is derived state rather than something the user picks. Attaching
|
||||
* one is a plain `UPDATE task_lists SET account_id = ?` — not the full data
|
||||
* migration it was under the dmfs provider, whose `ACCOUNT_TYPE` was write-once.
|
||||
*/
|
||||
enum class StorageMode {
|
||||
/**
|
||||
* Agendula's own Room database. The default, and always available: there is
|
||||
* no authority, no ContentResolver and no permission to grant.
|
||||
*/
|
||||
OWN,
|
||||
|
||||
/**
|
||||
* A tasks provider app already on the device (OpenTasks, tasks.org), synced by
|
||||
* whatever that provider's engine is — DAVx5 and friends. Still fully
|
||||
* supported, now a choice rather than the only way. Requires that provider's
|
||||
* runtime read/write permissions.
|
||||
*/
|
||||
EXTERNAL,
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Mirrors the stored [StorageMode] into [ProviderResolver].
|
||||
*
|
||||
* The resolver is consulted synchronously from every data-source call and from
|
||||
* `providerStatus()` on the main thread, so it cannot read DataStore itself.
|
||||
* This is the one component that bridges the two: it collects the preference for
|
||||
* the life of the process and pushes each value across.
|
||||
*
|
||||
* [awaitReady] exists for the startup race. Until the first DataStore emission
|
||||
* arrives the resolver's mode is `null` and `ProviderResolver.autoMode` answers
|
||||
* instead — fine as a steady state, wrong for a user who explicitly chose the
|
||||
* other mode. Anything that touches the provider before the UI is up (the launch
|
||||
* reminder re-sync, notably) should wait rather than risk reading the wrong
|
||||
* store and rescheduling every alarm off it.
|
||||
*/
|
||||
@Singleton
|
||||
class StorageModeHolder @Inject constructor(
|
||||
private val prefs: SettingsPrefs,
|
||||
private val resolver: ProviderResolver,
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
) {
|
||||
|
||||
private val firstValue = CompletableDeferred<Unit>()
|
||||
|
||||
/** Starts mirroring. Idempotent in effect; call once, from `Application.onCreate`. */
|
||||
fun start() {
|
||||
scope.launch {
|
||||
prefs.storageMode.collect { mode ->
|
||||
resolver.storageMode = mode
|
||||
firstValue.complete(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Suspends until the stored mode has been applied at least once. */
|
||||
suspend fun awaitReady() = firstValue.await()
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.agendula.domain.export.ExportTask
|
||||
import de.jeanlucmakiola.agendula.domain.priorityFromICal
|
||||
import de.jeanlucmakiola.agendula.domain.statusFromInt
|
||||
import kotlin.time.Instant
|
||||
@@ -16,10 +17,17 @@ object TaskMapper {
|
||||
fun instant(name: String): Instant? =
|
||||
r.getLong(name)?.let { Instant.fromEpochMilliseconds(it) }
|
||||
|
||||
val instanceId = r.getLong(Tasks.ID) ?: 0L
|
||||
val rowId = r.getLong(Tasks.ID) ?: 0L
|
||||
// Derived from the rule columns rather than the `is_recurring` column
|
||||
// alone: that column only exists from OpenTasks 1.4.0 (DB 23) and is
|
||||
// absent on tasks.org's bundled provider (DB 22), where reading it
|
||||
// would silently report every recurring task as one-off — and route
|
||||
// its edits onto the series anchor.
|
||||
val recurring = r.getString(Tasks.RRULE) != null ||
|
||||
r.getString(Tasks.RDATE) != null ||
|
||||
r.getBoolean(Instances.IS_RECURRING)
|
||||
return Task(
|
||||
id = instanceId,
|
||||
taskId = r.getLong(Instances.TASK_ID) ?: instanceId,
|
||||
taskId = r.getLong(Instances.TASK_ID) ?: rowId,
|
||||
listId = r.getLong(Tasks.LIST_ID) ?: 0L,
|
||||
title = r.getString(Tasks.TITLE).orEmpty(),
|
||||
description = r.getString(Tasks.DESCRIPTION),
|
||||
@@ -38,13 +46,66 @@ object TaskMapper {
|
||||
listName = r.getString(Tasks.LIST_NAME),
|
||||
accountName = r.getString(Tasks.ACCOUNT_NAME),
|
||||
parentId = r.getLong(Tasks.PARENT_ID),
|
||||
isRecurring = r.getBoolean(Instances.IS_RECURRING),
|
||||
isRecurring = recurring,
|
||||
occurrenceStart = if (recurring) occurrenceAnchor(r) else null,
|
||||
distanceFromCurrent = r.getInt(Instances.DISTANCE_FROM_CURRENT),
|
||||
created = instant(Tasks.CREATED),
|
||||
lastModified = instant(Tasks.LAST_MODIFIED),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The occurrence's `RECURRENCE-ID` anchor.
|
||||
*
|
||||
* `instance_original_time` is the provider's own name for it and is set on
|
||||
* every occurrence of a recurring task, so it is read first. It is absent on
|
||||
* older provider schemas, where the fallbacks reconstruct the same value: a
|
||||
* DTSTART-anchored series instantiates each occurrence at its start, and a
|
||||
* series carrying only DUE anchors on the due date instead.
|
||||
*/
|
||||
fun occurrenceAnchor(r: ColumnReader): Instant? =
|
||||
(
|
||||
r.getLong(Instances.INSTANCE_ORIGINAL_TIME)
|
||||
?: r.getLong(Instances.INSTANCE_START)
|
||||
?: r.getLong(Instances.INSTANCE_DUE)
|
||||
)?.let { Instant.fromEpochMilliseconds(it) }
|
||||
|
||||
/**
|
||||
* Maps a row of the **`tasks` table** — a master task, not an occurrence.
|
||||
*
|
||||
* Export reads there rather than from `instances` on purpose: in the instances
|
||||
* view a recurring task appears once per occurrence with its times already
|
||||
* resolved and no rule attached, so exporting from it would write the same
|
||||
* task many times over and drop the RRULE that produced them. Here each task
|
||||
* appears exactly once, carrying the rule itself.
|
||||
*/
|
||||
fun exportTask(r: ColumnReader): ExportTask {
|
||||
fun instant(name: String): Instant? =
|
||||
r.getLong(name)?.let { Instant.fromEpochMilliseconds(it) }
|
||||
|
||||
return ExportTask(
|
||||
taskId = r.getLong(Tasks.ID) ?: 0L,
|
||||
uid = r.getString(Tasks.UID),
|
||||
title = r.getString(Tasks.TITLE).orEmpty(),
|
||||
description = r.getString(Tasks.DESCRIPTION),
|
||||
location = r.getString(Tasks.LOCATION),
|
||||
url = r.getString(Tasks.URL),
|
||||
priority = priorityFromICal(r.getInt(Tasks.PRIORITY)),
|
||||
status = statusFromInt(r.getInt(Tasks.STATUS)),
|
||||
percentComplete = r.getInt(Tasks.PERCENT_COMPLETE),
|
||||
// The task's own columns, not the instance view's resolved ones.
|
||||
start = instant(Tasks.DTSTART),
|
||||
due = instant(Tasks.DUE),
|
||||
isAllDay = r.getBoolean(Tasks.IS_ALLDAY),
|
||||
completedAt = instant(Tasks.COMPLETED),
|
||||
created = instant(Tasks.CREATED),
|
||||
lastModified = instant(Tasks.LAST_MODIFIED),
|
||||
rrule = r.getString(Tasks.RRULE),
|
||||
rdate = r.getString(Tasks.RDATE),
|
||||
parentId = r.getLong(Tasks.PARENT_ID)?.takeIf { it > 0 },
|
||||
)
|
||||
}
|
||||
|
||||
fun taskList(r: ColumnReader): TaskList = TaskList(
|
||||
id = r.getLong(Lists.ID) ?: 0L,
|
||||
name = r.getString(Lists.NAME).orEmpty(),
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
||||
|
||||
/** Column lists requested from the provider. Order is irrelevant; we read by name. */
|
||||
object TaskProjections {
|
||||
@@ -18,31 +16,9 @@ object TaskProjections {
|
||||
Lists.ACCOUNT_TYPE,
|
||||
)
|
||||
|
||||
/** Read from the `instances` view (inherits all task columns). */
|
||||
val INSTANCES: Array<String> = arrayOf(
|
||||
Tasks.ID,
|
||||
Instances.TASK_ID,
|
||||
Tasks.LIST_ID,
|
||||
Tasks.TITLE,
|
||||
Tasks.DESCRIPTION,
|
||||
Tasks.LOCATION,
|
||||
Tasks.URL,
|
||||
Tasks.PRIORITY,
|
||||
Tasks.STATUS,
|
||||
Tasks.PERCENT_COMPLETE,
|
||||
Tasks.COMPLETED,
|
||||
Tasks.IS_ALLDAY,
|
||||
Tasks.TZ,
|
||||
Instances.INSTANCE_START,
|
||||
Instances.INSTANCE_DUE,
|
||||
Tasks.TASK_COLOR,
|
||||
Tasks.LIST_COLOR,
|
||||
Tasks.LIST_NAME,
|
||||
Tasks.ACCOUNT_NAME,
|
||||
Tasks.PARENT_ID,
|
||||
Instances.IS_RECURRING,
|
||||
Instances.DISTANCE_FROM_CURRENT,
|
||||
Tasks.CREATED,
|
||||
Tasks.LAST_MODIFIED,
|
||||
)
|
||||
// No `instances` projection on purpose: that read passes `projection = null`
|
||||
// (all columns), because the view's shape differs across provider versions —
|
||||
// tasks.org's bundled OpenTasks has no `is_recurring`, for one. A fixed list
|
||||
// here would drift out of sync with the by-name mapper and quietly drop
|
||||
// columns it depends on. See AndroidTasksDataSource.queryInstances.
|
||||
}
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Alarm
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import de.jeanlucmakiola.agendula.domain.toICal
|
||||
import kotlin.time.Instant
|
||||
|
||||
private const val MILLIS_PER_DAY = 24L * 60 * 60 * 1000
|
||||
|
||||
/** Floor to UTC midnight when [allDay], else pass through unchanged. */
|
||||
private fun Instant.forAllDay(allDay: Boolean): Instant =
|
||||
if (!allDay) this
|
||||
else Instant.fromEpochMilliseconds(
|
||||
Math.floorDiv(toEpochMilliseconds(), MILLIS_PER_DAY) * MILLIS_PER_DAY,
|
||||
)
|
||||
|
||||
/**
|
||||
* Turns a [TaskForm] / mutation into a name→value map. Pure (no ContentValues),
|
||||
@@ -39,8 +51,17 @@ object TaskWriteMapper {
|
||||
}
|
||||
}
|
||||
put(Tasks.IS_ALLDAY, if (form.isAllDay) 1 else 0)
|
||||
put(Tasks.DTSTART, form.start?.toEpochMilliseconds())
|
||||
put(Tasks.DUE, form.due?.toEpochMilliseconds())
|
||||
// All-day tasks are date-only in iCalendar. The provider reads them back
|
||||
// through DateTime.toAllDay(), which drops the time-of-day and resolves the
|
||||
// remaining date against UTC — so a local-midnight instant lands on the
|
||||
// previous day for anyone west of UTC. Pin all-day values to UTC midnight.
|
||||
put(Tasks.DTSTART, form.start?.forAllDay(form.isAllDay)?.toEpochMilliseconds())
|
||||
put(Tasks.DUE, form.due?.forAllDay(form.isAllDay)?.toEpochMilliseconds())
|
||||
// DUE and DURATION are mutually exclusive. The provider's Validating
|
||||
// processor evaluates the *merged* row (supplied values over the stored
|
||||
// ones), so writing DUE onto a task that already carries a DURATION throws
|
||||
// "Only one of DUE or DURATION must be supplied." Clear it alongside.
|
||||
put(Tasks.DURATION, null)
|
||||
put(Tasks.PARENT_ID, form.parentId)
|
||||
// The provider treats a null tz as local time; set it explicitly for
|
||||
// timed tasks so the stored instant is unambiguous across zones.
|
||||
@@ -48,6 +69,16 @@ object TaskWriteMapper {
|
||||
put(Tasks.TZ, if (timed) tzId else null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Values for an update through the *instances* URI (a recurring occurrence).
|
||||
* The provider clones the row into an override and strips list/recurrence
|
||||
* fields as it goes, so LIST_ID and PARENT_ID are dropped here rather than
|
||||
* written and silently ignored — moving one occurrence between lists or
|
||||
* parents isn't a thing the override model expresses.
|
||||
*/
|
||||
fun instanceValues(form: TaskForm, tzId: String): Map<String, Any?> =
|
||||
taskValues(form, tzId) - Tasks.LIST_ID - Tasks.PARENT_ID
|
||||
|
||||
fun completionValues(completed: Boolean, nowMillis: Long): Map<String, Any?> =
|
||||
if (completed) {
|
||||
mapOf(
|
||||
@@ -63,9 +94,26 @@ object TaskWriteMapper {
|
||||
)
|
||||
}
|
||||
|
||||
fun localListValues(name: String, color: Int): Map<String, Any?> = mapOf(
|
||||
/**
|
||||
* A reminder for [taskId], as an Alarm property row. The provider's validator
|
||||
* requires MINUTES_BEFORE, REFERENCE (non-negative) and ALARM_TYPE on every
|
||||
* write, so all three are always present.
|
||||
*/
|
||||
fun alarmValues(taskId: Long, minutesBeforeDue: Int): Map<String, Any?> = mapOf(
|
||||
Properties.TASK_ID to taskId,
|
||||
Properties.MIMETYPE to Alarm.MIMETYPE,
|
||||
Alarm.MINUTES_BEFORE to minutesBeforeDue,
|
||||
Alarm.REFERENCE to Alarm.REFERENCE_DUE,
|
||||
Alarm.ALARM_TYPE to Alarm.TYPE_MESSAGE,
|
||||
)
|
||||
|
||||
/** The user-owned columns of a list — what an edit is allowed to change. */
|
||||
fun listValues(name: String, color: Int): Map<String, Any?> = mapOf(
|
||||
Lists.NAME to name.trim(),
|
||||
Lists.COLOR to color,
|
||||
)
|
||||
|
||||
fun localListValues(name: String, color: Int): Map<String, Any?> = listValues(name, color) + mapOf(
|
||||
Lists.ACCOUNT_NAME to TasksContract.LOCAL_ACCOUNT_NAME,
|
||||
Lists.ACCOUNT_TYPE to TasksContract.LOCAL_ACCOUNT_TYPE,
|
||||
Lists.VISIBLE to 1,
|
||||
|
||||
@@ -66,6 +66,9 @@ object TasksContract {
|
||||
const val IS_ALLDAY = "is_allday"
|
||||
const val TZ = "tz"
|
||||
const val RRULE = "rrule"
|
||||
const val RDATE = "rdate"
|
||||
/** Set on an override row — the master occurrence this one replaces. */
|
||||
const val ORIGINAL_INSTANCE_ID = "original_instance_id"
|
||||
const val PARENT_ID = "parent_id"
|
||||
const val SORTING = "sorting"
|
||||
const val CREATED = "created"
|
||||
@@ -96,8 +99,59 @@ object TasksContract {
|
||||
const val INSTANCE_DUE_SORTING = "instance_due_sorting"
|
||||
const val DISTANCE_FROM_CURRENT = "distance_from_current"
|
||||
const val IS_RECURRING = "is_recurring"
|
||||
|
||||
/**
|
||||
* The occurrence's `RECURRENCE-ID` — the time this occurrence was
|
||||
* instantiated at, before any override moved it. Set on every occurrence
|
||||
* of a recurring task, which is what makes it the occurrence's identity.
|
||||
*/
|
||||
const val INSTANCE_ORIGINAL_TIME = "instance_original_time"
|
||||
}
|
||||
|
||||
/** The `properties` table — per-task side rows, discriminated by [Properties.MIMETYPE]. */
|
||||
object Properties {
|
||||
const val PATH = "properties"
|
||||
const val PROPERTY_ID = "property_id"
|
||||
const val TASK_ID = "task_id"
|
||||
const val MIMETYPE = "mimetype"
|
||||
}
|
||||
|
||||
/**
|
||||
* An alarm property row — a per-task reminder lead.
|
||||
*
|
||||
* Storage and sync format *only*: the provider fires nothing (its alarm
|
||||
* scheduling is commented out and the internal `alarms` table is never
|
||||
* populated), so [de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler]
|
||||
* still arms the real AlarmManager alarm. Writing it here is what makes the
|
||||
* lead survive a sync and show up in other OpenTasks clients.
|
||||
*
|
||||
* The columns are the generic `dataN` slots; the meanings below are the
|
||||
* Alarm property's contract for them.
|
||||
*/
|
||||
object Alarm {
|
||||
const val MIMETYPE = "vnd.android.cursor.item/alarm"
|
||||
|
||||
/** `data0` — minutes from the reference date; positive means *before* it. */
|
||||
const val MINUTES_BEFORE = "data0"
|
||||
|
||||
/** `data1` — which date to count from. */
|
||||
const val REFERENCE = "data1"
|
||||
|
||||
/** `data2` — optional message shown with the alarm. */
|
||||
const val MESSAGE = "data2"
|
||||
|
||||
/** `data3` — alarm kind. Must be present, and non-zero to count as an alarm. */
|
||||
const val ALARM_TYPE = "data3"
|
||||
|
||||
const val REFERENCE_DUE = 1
|
||||
const val REFERENCE_START = 2
|
||||
|
||||
/** 0 (NOTHING) is excluded from the provider's `has_alarms` count — use MESSAGE. */
|
||||
const val TYPE_MESSAGE = 1
|
||||
}
|
||||
|
||||
fun propertiesUri(authority: String): Uri = Uri.parse("content://$authority/${Properties.PATH}")
|
||||
|
||||
// --- status values (TaskColumns.STATUS_*) --------------------------------
|
||||
const val STATUS_NEEDS_ACTION = 0
|
||||
const val STATUS_IN_PROCESS = 1
|
||||
@@ -109,9 +163,20 @@ object TasksContract {
|
||||
|
||||
fun authorityUri(authority: String): Uri = Uri.parse("content://$authority")
|
||||
fun listsUri(authority: String): Uri = Uri.parse("content://$authority/${Lists.PATH}")
|
||||
fun listUri(authority: String, listId: Long): Uri =
|
||||
Uri.parse("content://$authority/${Lists.PATH}/$listId")
|
||||
fun tasksUri(authority: String): Uri = Uri.parse("content://$authority/${Tasks.PATH}")
|
||||
fun instancesUri(authority: String): Uri = Uri.parse("content://$authority/${Instances.PATH}")
|
||||
|
||||
/**
|
||||
* A single occurrence. Updating through this URI is how a *recurring* task is
|
||||
* edited: the provider clones the row into an override task
|
||||
* (`original_instance_id` set, recurrence fields stripped) instead of moving
|
||||
* the series anchor, which is what writing to `tasks/<id>` would do.
|
||||
*/
|
||||
fun instanceUri(authority: String, instanceId: Long): Uri =
|
||||
Uri.parse("content://$authority/${Instances.PATH}/$instanceId")
|
||||
|
||||
/** Append the sync-adapter params required to write local-account rows. */
|
||||
fun asSyncAdapter(uri: Uri, accountName: String, accountType: String): Uri =
|
||||
uri.buildUpon()
|
||||
|
||||
@@ -3,6 +3,17 @@ package de.jeanlucmakiola.agendula.data.tasks
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import 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)
|
||||
|
||||
/** What to fetch from the provider. Smart-list date logic is applied above this. */
|
||||
data class TaskQuery(
|
||||
@@ -23,10 +34,67 @@ interface TasksDataSource {
|
||||
|
||||
fun insertTask(form: TaskForm): Long
|
||||
fun updateTask(taskId: Long, form: TaskForm)
|
||||
|
||||
/**
|
||||
* Update a single occurrence of a recurring task, addressed by the task row and
|
||||
* the occurrence's `RECURRENCE-ID` anchor ([Task.occurrenceStart]). The store
|
||||
* forks an override rather than moving the series anchor — which is what
|
||||
* [updateTask] would do, since a recurring task's start/due are the
|
||||
* occurrence's resolved times.
|
||||
*
|
||||
* Addressing by `(taskId, occurrenceStart)` rather than by a materialised
|
||||
* instance row id keeps this seam independent of any one store's row
|
||||
* numbering; External mode maps it back to an instance row itself.
|
||||
*/
|
||||
fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm)
|
||||
/**
|
||||
* Set (or clear, with `null`) the per-task reminder lead, stored as an Alarm
|
||||
* property row. 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?)
|
||||
|
||||
/** Every task's reminder, by task id. One query, for the scheduler. */
|
||||
fun alarms(): Map<Long, TaskReminder>
|
||||
|
||||
/**
|
||||
* Every task in [listId] read from the **`tasks` table**, for export. Masters,
|
||||
* not occurrences — see [TaskMapper.exportTask] for why that distinction
|
||||
* matters. Excludes rows the provider has flagged deleted-but-unsynced.
|
||||
*/
|
||||
fun exportTasks(listId: Long): List<de.jeanlucmakiola.agendula.domain.export.ExportTask>
|
||||
|
||||
fun setCompleted(taskId: Long, completed: Boolean)
|
||||
|
||||
/**
|
||||
* Complete (or reopen) **one occurrence** of a recurring task, addressed the
|
||||
* same way [updateInstance] is. Ticking a series through [setCompleted] would
|
||||
* close the master row, which takes every past and future occurrence out of
|
||||
* every list at once.
|
||||
*
|
||||
* Implementations fall back to [setCompleted] when the row turns out not to
|
||||
* be a series master — an override, or a plain task the caller happened to
|
||||
* hand an anchor for — so the routing above cannot get this wrong.
|
||||
*/
|
||||
fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean)
|
||||
fun deleteTask(taskId: Long)
|
||||
fun createLocalList(name: String, color: Int): Long
|
||||
|
||||
/** Rename and recolour [listId]. */
|
||||
fun updateList(listId: Long, name: String, color: Int)
|
||||
|
||||
/**
|
||||
* Delete [listId] **and the tasks in it** — `tasks.list_id` cascades on the
|
||||
* Room path, and the provider does the same on the External one.
|
||||
*
|
||||
* Only ever called for a local, device-only list: a collection that belongs
|
||||
* to an account is the server's to remove, and neither store expresses a
|
||||
* collection tombstone yet. The UI gates on [TaskList.isLocal]; this seam
|
||||
* does not re-check it.
|
||||
*/
|
||||
fun deleteList(listId: Long)
|
||||
|
||||
/** Observe any change to tasks/lists; [onChange] fires on a background thread. */
|
||||
fun registerObserver(onChange: () -> Unit): AutoCloseable
|
||||
}
|
||||
|
||||
@@ -37,9 +37,25 @@ interface TasksRepository {
|
||||
* since the form loaded. Pass `null` to force the write (overwrite-anyway).
|
||||
*/
|
||||
suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant? = null)
|
||||
suspend fun setCompleted(taskId: Long, completed: Boolean)
|
||||
/**
|
||||
* Complete or reopen a task. Pass the occurrence's [Task.occurrenceStart] so a
|
||||
* recurring series forks a `RECURRENCE-ID` override for that one occurrence
|
||||
* instead of closing the whole series; `null` completes the row itself.
|
||||
*/
|
||||
suspend fun setCompleted(taskId: Long, occurrenceStart: Instant?, completed: Boolean)
|
||||
suspend fun deleteTask(taskId: Long)
|
||||
|
||||
/**
|
||||
* 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?
|
||||
suspend fun createLocalList(name: String, color: Int): Long
|
||||
suspend fun updateList(listId: Long, name: String, color: Int)
|
||||
|
||||
/** Deletes the list **and its tasks**. Local lists only — see [TasksDataSource.deleteList]. */
|
||||
suspend fun deleteList(listId: Long)
|
||||
|
||||
/** Synchronous snapshot for the permission/onboarding gate. */
|
||||
fun providerStatus(): ProviderStatus
|
||||
|
||||
@@ -28,6 +28,7 @@ import kotlin.time.Instant
|
||||
class TasksRepositoryImpl @Inject constructor(
|
||||
private val dataSource: TasksDataSource,
|
||||
private val providerResolver: ProviderResolver,
|
||||
private val startupGate: StartupGate,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : TasksRepository {
|
||||
|
||||
@@ -80,22 +81,50 @@ class TasksRepositoryImpl @Inject constructor(
|
||||
}
|
||||
|
||||
override suspend fun createTask(form: TaskForm): Long =
|
||||
withContext(io) { dataSource.insertTask(form) }
|
||||
withContext(io) {
|
||||
val id = dataSource.insertTask(form)
|
||||
form.reminderMinutesBeforeDue?.let { dataSource.setAlarm(id, it) }
|
||||
id
|
||||
}
|
||||
|
||||
override suspend fun reminderFor(taskId: Long): Int? =
|
||||
withContext(io) { runCatching { dataSource.alarms()[taskId]?.minutesBefore }.getOrNull() }
|
||||
|
||||
override suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant?) =
|
||||
withContext(io) {
|
||||
// Conflict-safe overwrite: re-read just before writing and bail if the
|
||||
// provider's last_modified moved since the form captured it (external
|
||||
// sync / another app). A null baseline means "force / overwrite anyway".
|
||||
// Re-read just before writing: it settles the conflict check *and* tells
|
||||
// us which URI to write through.
|
||||
val current = dataSource.task(taskId)
|
||||
// Conflict-safe overwrite: bail if the provider's last_modified moved
|
||||
// since the form captured it (external sync / another app). A null
|
||||
// baseline means "force / overwrite anyway".
|
||||
if (expectedLastModified != null) {
|
||||
val current = dataSource.task(taskId)?.lastModified
|
||||
if (current != null && current != expectedLastModified) throw TaskConflictException(taskId)
|
||||
val seen = current?.lastModified
|
||||
if (seen != null && seen != expectedLastModified) throw TaskConflictException(taskId)
|
||||
}
|
||||
// Write the reminder first: forking a recurring occurrence copies the
|
||||
// task's properties onto the new override row, so setting the alarm
|
||||
// beforehand is what carries it across.
|
||||
dataSource.setAlarm(taskId, form.reminderMinutesBeforeDue)
|
||||
// A recurring task's start/due are one occurrence's resolved times, so
|
||||
// writing them back to the task row would re-anchor the whole series.
|
||||
// Going through the occurrence forks an override instead.
|
||||
val occurrence = current?.takeIf { it.isRecurring }?.occurrenceStart
|
||||
if (occurrence != null) {
|
||||
dataSource.updateInstance(taskId, occurrence, form)
|
||||
} else {
|
||||
dataSource.updateTask(taskId, form)
|
||||
}
|
||||
dataSource.updateTask(taskId, form)
|
||||
}
|
||||
|
||||
override suspend fun setCompleted(taskId: Long, completed: Boolean) =
|
||||
withContext(io) { dataSource.setCompleted(taskId, completed) }
|
||||
override suspend fun setCompleted(taskId: Long, occurrenceStart: Instant?, completed: Boolean) =
|
||||
withContext(io) {
|
||||
if (occurrenceStart != null) {
|
||||
dataSource.setCompletedInstance(taskId, occurrenceStart, completed)
|
||||
} else {
|
||||
dataSource.setCompleted(taskId, completed)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun deleteTask(taskId: Long) =
|
||||
withContext(io) { dataSource.deleteTask(taskId) }
|
||||
@@ -103,18 +132,32 @@ class TasksRepositoryImpl @Inject constructor(
|
||||
override suspend fun createLocalList(name: String, color: Int): Long =
|
||||
withContext(io) { dataSource.createLocalList(name, color) }
|
||||
|
||||
override suspend fun updateList(listId: Long, name: String, color: Int) =
|
||||
withContext(io) { dataSource.updateList(listId, name, color) }
|
||||
|
||||
override suspend fun deleteList(listId: Long) =
|
||||
withContext(io) { dataSource.deleteList(listId) }
|
||||
|
||||
override fun providerStatus(): ProviderStatus {
|
||||
// Our own store is always ready: it ships with the app, needs no provider
|
||||
// and no grant. The permission gate only ever applied to External mode —
|
||||
// now that is visibly true rather than a special case inside it.
|
||||
if (providerResolver.mode() == StorageMode.OWN) return ProviderStatus.READY
|
||||
val provider = providerResolver.resolve() ?: return ProviderStatus.NO_PROVIDER
|
||||
return if (providerResolver.hasPermission(provider)) ProviderStatus.READY
|
||||
else ProviderStatus.NEEDS_PERMISSION
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits an initial load, then re-loads on every provider change. The observer
|
||||
* Emits an initial load, then re-loads on every store change. The observer
|
||||
* callback (main thread) only pokes a conflated channel; the actual blocking
|
||||
* query runs on [io].
|
||||
*/
|
||||
private fun <T> observing(load: () -> T): Flow<T> = callbackFlow {
|
||||
// Nothing reads a store before the stored mode has landed and a v0.3.x
|
||||
// install has been imported — otherwise the first emission comes from the
|
||||
// wrong store, or from an empty one.
|
||||
startupGate.awaitReady()
|
||||
val ticks = Channel<Unit>(Channel.CONFLATED)
|
||||
val handle = dataSource.registerObserver { ticks.trySend(Unit) }
|
||||
ticks.trySend(Unit) // prime the initial emission
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.legacy
|
||||
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.agendula.data.tasks.CursorColumnReader
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
|
||||
import de.jeanlucmakiola.agendula.domain.statusFromInt
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** How much one import moved. */
|
||||
data class ImportCounts(val lists: Int, val tasks: Int, val alarms: Int)
|
||||
|
||||
/** The outcome of [OneShotImport.runIfNeeded] or [OneShotImport.reimportFromArchive]. */
|
||||
sealed interface ImportResult {
|
||||
/** The DataStore flag was already set; nothing was read. */
|
||||
data object AlreadyDone : ImportResult
|
||||
|
||||
/** No legacy database on disk — a fresh install, or one already archived. */
|
||||
data object NothingToImport : ImportResult
|
||||
|
||||
data class Imported(val counts: ImportCounts) : ImportResult
|
||||
|
||||
/** Nothing landed: the transaction rolled back and the source is untouched. */
|
||||
data class Failed(val cause: Throwable) : ImportResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a v0.3.x install's tasks out of the bundled dmfs provider's SQLite file
|
||||
* and into Room, once (`docs/OWN-STORE.md`, "Migrating existing users").
|
||||
*
|
||||
* The file is opened read-only and directly — no provider, no ContentResolver —
|
||||
* so this keeps working after `:provider` is deleted. Everything lands in one
|
||||
* Room transaction with verified counts, so a failure leaves Room exactly as it
|
||||
* was and the source file exactly where it was.
|
||||
*
|
||||
* dmfs accounts are not carried over: every list is imported as a device-only
|
||||
* list (`account_id IS NULL`), including one that sat under a real account —
|
||||
* only reachable if the user had pointed DAVx5 at our authority. Their task
|
||||
* `uid`s are preserved, which is what lets those rows be re-attached to an
|
||||
* account once sync lands.
|
||||
*/
|
||||
@Singleton
|
||||
class OneShotImport @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val database: TasksDatabase,
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) {
|
||||
|
||||
/** Whether the import has run. Set before the rename, so both guards hold. */
|
||||
val isDone: Flow<Boolean> = dataStore.data.map { it[IMPORT_DONE] ?: false }
|
||||
|
||||
/**
|
||||
* Steps 1–8 of the plan: archive `databases/tasks.db`, import it, record
|
||||
* completion. Safe to call on every launch.
|
||||
*
|
||||
* The archive happens *before* the import, and the import always replaces, so
|
||||
* that every point this can be killed at re-enters correctly:
|
||||
*
|
||||
* - killed after the rename, before the import — the next run finds the
|
||||
* archive, imports it, and nothing is lost;
|
||||
* - killed after the import commits, before the flag is written — the next
|
||||
* run truncates and re-imports the same archive, so the result is the same
|
||||
* rather than doubled.
|
||||
*
|
||||
* Renaming last would leave that second window open: the flag would be unset
|
||||
* and `tasks.db` still in place, and the next launch would import it a second
|
||||
* time on top of the first. That is the window the plan's "guarded by a
|
||||
* DataStore flag *and* by the rename" is meant to close, and only this order
|
||||
* actually closes it.
|
||||
*/
|
||||
suspend fun runIfNeeded(): ImportResult = withContext(Dispatchers.IO) {
|
||||
if (isDone.first()) return@withContext ImportResult.AlreadyDone
|
||||
val source = archivedSource() ?: run {
|
||||
markDone()
|
||||
return@withContext ImportResult.NothingToImport
|
||||
}
|
||||
val counts = runCatching { importFrom(source, replaceExisting = true) }
|
||||
.getOrElse { return@withContext ImportResult.Failed(it) }
|
||||
markDone()
|
||||
ImportResult.Imported(counts)
|
||||
}
|
||||
|
||||
/**
|
||||
* The rollback path: re-run against the archived `tasks.db.imported`,
|
||||
* truncating the Room tables first so a second attempt replaces rather than
|
||||
* merges. Reached by a targeted fix release, not by the app on its own.
|
||||
*/
|
||||
suspend fun reimportFromArchive(): ImportResult = withContext(Dispatchers.IO) {
|
||||
val source = archivedSource() ?: return@withContext ImportResult.NothingToImport
|
||||
val counts = runCatching { importFrom(source, replaceExisting = true) }
|
||||
.getOrElse { return@withContext ImportResult.Failed(it) }
|
||||
markDone()
|
||||
ImportResult.Imported(counts)
|
||||
}
|
||||
|
||||
/**
|
||||
* The legacy database as `tasks.db.imported`, archiving it first if it is
|
||||
* still under its live name. `null` when there is nothing to import.
|
||||
*/
|
||||
private fun archivedSource(): File? {
|
||||
val archive = context.getDatabasePath(ARCHIVE_NAME)
|
||||
if (archive.exists()) return archive
|
||||
val live = context.getDatabasePath(LEGACY_NAME)
|
||||
if (!live.exists()) return null
|
||||
return if (archive(live)) archive else live
|
||||
}
|
||||
|
||||
/** Clears the completion flag so [runIfNeeded] will import again. */
|
||||
suspend fun clearCompletion() {
|
||||
dataStore.edit { it.remove(IMPORT_DONE) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Steps 2–6 against an arbitrary dmfs database: read it read-only, then write
|
||||
* everything in one Room transaction whose counts are verified before it
|
||||
* commits. Blocking — call it off the main thread.
|
||||
*/
|
||||
fun importFrom(source: File, replaceExisting: Boolean = false): ImportCounts {
|
||||
val snapshot = SQLiteDatabase.openDatabase(source.path, null, SQLiteDatabase.OPEN_READONLY)
|
||||
.use(::read)
|
||||
return database.runInTransaction<ImportCounts> {
|
||||
if (replaceExisting) truncate()
|
||||
val baseline = tableCounts()
|
||||
val written = write(snapshot)
|
||||
verify(written, baseline)
|
||||
written
|
||||
}
|
||||
}
|
||||
|
||||
// --- reading the dmfs file ------------------------------------------------
|
||||
|
||||
private fun read(db: SQLiteDatabase): LegacySnapshot {
|
||||
val lists = mutableListOf<LegacyList>()
|
||||
db.rawQuery("SELECT * FROM Lists ORDER BY _id", null).use { cursor ->
|
||||
val r = CursorColumnReader(cursor)
|
||||
while (cursor.moveToNext()) {
|
||||
val id = r.getLong("_id") ?: continue
|
||||
lists += LegacyList(
|
||||
id = id,
|
||||
entity = TaskListEntity(
|
||||
name = r.getString("list_name").orEmpty(),
|
||||
color = r.getInt("list_color") ?: 0,
|
||||
accountId = null,
|
||||
isVisible = r.getBoolean("visible"),
|
||||
isSynced = r.getBoolean("sync_enabled"),
|
||||
owner = r.getString("list_owner"),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val rows = mutableListOf<LegacyTaskRow>()
|
||||
db.rawQuery("SELECT * FROM Tasks WHERE _deleted IS NULL OR _deleted = 0 ORDER BY _id", null)
|
||||
.use { cursor ->
|
||||
val r = CursorColumnReader(cursor)
|
||||
while (cursor.moveToNext()) {
|
||||
val id = r.getLong("_id") ?: continue
|
||||
rows += LegacyTaskRow(
|
||||
id = id,
|
||||
listId = r.getLong("list_id") ?: continue,
|
||||
parentId = r.getLong("parent_id"),
|
||||
masterId = r.getLong("original_instance_id"),
|
||||
recurrenceId = r.instant("original_instance_time"),
|
||||
entity = TaskEntity(
|
||||
listId = 0,
|
||||
uid = r.getString("_uid") ?: UUID.randomUUID().toString(),
|
||||
title = r.getString("title"),
|
||||
description = r.getString("description"),
|
||||
location = r.getString("location"),
|
||||
url = r.getString("url"),
|
||||
color = r.getInt("task_color"),
|
||||
status = statusFromInt(r.getInt("status")),
|
||||
percentComplete = r.getInt("percent_complete"),
|
||||
completedAt = r.instant("completed"),
|
||||
priority = r.getInt("priority") ?: PRIORITY_NONE,
|
||||
classification = r.getInt("class"),
|
||||
dtstart = r.instant("dtstart"),
|
||||
due = r.instant("due"),
|
||||
duration = r.getString("duration"),
|
||||
isAllDay = r.getBoolean("is_allday"),
|
||||
timezone = r.getString("tz"),
|
||||
rrule = r.getString("rrule"),
|
||||
rdate = r.getString("rdate"),
|
||||
exdate = r.getString("exdate"),
|
||||
createdAt = r.instant("created"),
|
||||
lastModified = r.instant("last_modified"),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val alarms = mutableListOf<LegacyAlarm>()
|
||||
db.rawQuery("SELECT task_id, mimetype, data0, data1, data2 FROM Properties", null)
|
||||
.use { cursor ->
|
||||
val r = CursorColumnReader(cursor)
|
||||
while (cursor.moveToNext()) {
|
||||
if (r.getString("mimetype") != ALARM_MIMETYPE) continue
|
||||
val taskId = r.getLong("task_id") ?: continue
|
||||
val minutes = r.getString("data0")?.trim()?.toIntOrNull() ?: continue
|
||||
alarms += LegacyAlarm(
|
||||
taskId = taskId,
|
||||
minutesBefore = minutes,
|
||||
reference = if (r.getString("data1")?.trim() == REFERENCE_START) {
|
||||
AlarmReference.START
|
||||
} else {
|
||||
AlarmReference.DUE
|
||||
},
|
||||
message = r.getString("data2"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return LegacySnapshot(lists, rows, alarms)
|
||||
}
|
||||
|
||||
// --- writing into Room ----------------------------------------------------
|
||||
|
||||
/**
|
||||
* dmfs `list_id`, `parent_id` and `original_instance_id` are old row ids, and
|
||||
* Room mints its own on insert, so every one of them is remapped through the
|
||||
* ids the inserts hand back. Tasks are inserted with their links cleared and
|
||||
* a second pass sets them, because a parent may be a higher `_id` than its
|
||||
* child.
|
||||
*/
|
||||
private fun write(snapshot: LegacySnapshot): ImportCounts {
|
||||
val listDao = database.taskLists()
|
||||
val taskDao = database.tasks()
|
||||
val alarmDao = database.alarms()
|
||||
|
||||
val listIds = snapshot.lists.associate { it.id to listDao.insert(it.entity) }
|
||||
|
||||
// A task whose list is missing is already invisible in dmfs — its tasks
|
||||
// view inner-joins Lists — so dropping it loses nothing the user could see.
|
||||
val importable = snapshot.tasks.filter { it.listId in listIds }
|
||||
val importableIds = importable.mapTo(mutableSetOf()) { it.id }
|
||||
val taskIds = mutableMapOf<Long, Long>()
|
||||
val inserted = mutableListOf<Pair<LegacyTaskRow, TaskEntity>>()
|
||||
val seen = mutableSetOf<Triple<Long, String, Instant?>>()
|
||||
|
||||
for (row in importable) {
|
||||
val listId = listIds.getValue(row.listId)
|
||||
val overrides = row.masterId != null && row.masterId in importableIds
|
||||
val recurrenceId = row.recurrenceId.takeIf { overrides }
|
||||
// A duplicate (list, uid, recurrence) would abort the whole import on
|
||||
// the unique index; a fresh uid costs the row nothing it still has.
|
||||
val uid = row.entity.uid.takeIf { seen.add(Triple(listId, it, recurrenceId)) }
|
||||
?: UUID.randomUUID().toString()
|
||||
val entity = row.entity.copy(listId = listId, uid = uid, recurrenceId = recurrenceId)
|
||||
val newId = taskDao.insert(entity)
|
||||
taskIds[row.id] = newId
|
||||
inserted += row to entity.copy(id = newId)
|
||||
}
|
||||
|
||||
for ((row, entity) in inserted) {
|
||||
val parentId = row.parentId?.let(taskIds::get)
|
||||
val masterId = row.masterId?.let(taskIds::get)
|
||||
if (parentId == null && masterId == null) continue
|
||||
taskDao.update(entity.copy(parentId = parentId, masterId = masterId))
|
||||
}
|
||||
|
||||
var alarmCount = 0
|
||||
for (alarm in snapshot.alarms) {
|
||||
val taskId = taskIds[alarm.taskId] ?: continue
|
||||
alarmDao.insert(
|
||||
TaskAlarmEntity(
|
||||
taskId = taskId,
|
||||
minutesBefore = alarm.minutesBefore,
|
||||
reference = alarm.reference,
|
||||
message = alarm.message,
|
||||
),
|
||||
)
|
||||
alarmCount++
|
||||
}
|
||||
|
||||
return ImportCounts(lists = listIds.size, tasks = taskIds.size, alarms = alarmCount)
|
||||
}
|
||||
|
||||
private fun verify(written: ImportCounts, before: ImportCounts) {
|
||||
val after = tableCounts()
|
||||
check(after.lists - before.lists == written.lists) {
|
||||
"list count mismatch: ${after.lists - before.lists} != ${written.lists}"
|
||||
}
|
||||
check(after.tasks - before.tasks == written.tasks) {
|
||||
"task count mismatch: ${after.tasks - before.tasks} != ${written.tasks}"
|
||||
}
|
||||
check(after.alarms - before.alarms == written.alarms) {
|
||||
"alarm count mismatch: ${after.alarms - before.alarms} != ${written.alarms}"
|
||||
}
|
||||
}
|
||||
|
||||
/** Dropping the lists takes their tasks and alarms with them, by cascade. */
|
||||
private fun truncate() {
|
||||
val listDao = database.taskLists()
|
||||
listDao.lists().forEach { listDao.delete(it.list.id) }
|
||||
}
|
||||
|
||||
private fun tableCounts() = ImportCounts(
|
||||
lists = count("task_lists"),
|
||||
tasks = count("tasks"),
|
||||
alarms = count("task_alarms"),
|
||||
)
|
||||
|
||||
private fun count(table: String): Int =
|
||||
database.query("SELECT COUNT(*) FROM $table", null).use {
|
||||
if (it.moveToFirst()) it.getInt(0) else 0
|
||||
}
|
||||
|
||||
// --- the source file ------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Renames the dmfs file, sidecars included, to `tasks.db.imported`. Never
|
||||
* deletes it: for one release it is the only way back if the import turns out
|
||||
* to be wrong on someone's device.
|
||||
*/
|
||||
private fun archive(source: File): Boolean {
|
||||
val target = File(source.parentFile, ARCHIVE_NAME)
|
||||
if (!source.renameTo(target)) return false
|
||||
for (suffix in SIDECARS) {
|
||||
val sidecar = File(source.path + suffix)
|
||||
if (sidecar.exists()) sidecar.renameTo(File(target.path + suffix))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private suspend fun markDone() {
|
||||
dataStore.edit { it[IMPORT_DONE] = true }
|
||||
}
|
||||
|
||||
private fun CursorColumnReader.instant(name: String): Instant? =
|
||||
getLong(name)?.let(Instant::fromEpochMilliseconds)
|
||||
|
||||
companion object {
|
||||
const val LEGACY_NAME = "tasks.db"
|
||||
const val ARCHIVE_NAME = "tasks.db.imported"
|
||||
|
||||
private const val ALARM_MIMETYPE = "vnd.android.cursor.item/alarm"
|
||||
private const val REFERENCE_START = "2"
|
||||
private val SIDECARS = listOf("-journal", "-wal", "-shm")
|
||||
private val IMPORT_DONE = booleanPreferencesKey("legacy_import_done")
|
||||
}
|
||||
}
|
||||
|
||||
private class LegacySnapshot(
|
||||
val lists: List<LegacyList>,
|
||||
val tasks: List<LegacyTaskRow>,
|
||||
val alarms: List<LegacyAlarm>,
|
||||
)
|
||||
|
||||
private class LegacyList(val id: Long, val entity: TaskListEntity)
|
||||
|
||||
private class LegacyTaskRow(
|
||||
val id: Long,
|
||||
val listId: Long,
|
||||
val parentId: Long?,
|
||||
val masterId: Long?,
|
||||
val recurrenceId: Instant?,
|
||||
val entity: TaskEntity,
|
||||
)
|
||||
|
||||
private class LegacyAlarm(
|
||||
val taskId: Long,
|
||||
val minutesBefore: Int,
|
||||
val reference: AlarmReference,
|
||||
val message: String?,
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
import androidx.room.Update
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** Reads and writes over `accounts`. Unused until sync lands. */
|
||||
@Dao
|
||||
interface AccountDao {
|
||||
|
||||
@Query("SELECT * FROM accounts ORDER BY display_name")
|
||||
fun all(): List<AccountEntity>
|
||||
|
||||
@Query("SELECT * FROM accounts WHERE id = :accountId")
|
||||
fun account(accountId: Long): AccountEntity?
|
||||
|
||||
@Insert
|
||||
fun insert(account: AccountEntity): Long
|
||||
|
||||
@Update
|
||||
fun update(account: AccountEntity)
|
||||
|
||||
@Query("UPDATE accounts SET last_sync_at = :at, last_sync_error = :error WHERE id = :accountId")
|
||||
fun recordSync(accountId: Long, at: Instant?, error: String?)
|
||||
|
||||
@Query("DELETE FROM accounts WHERE id = :accountId")
|
||||
fun delete(accountId: Long): Int
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import de.jeanlucmakiola.agendula.domain.statusFromInt
|
||||
import de.jeanlucmakiola.agendula.domain.toInt
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Storage encodings for the entity types SQLite has no column type for. Time is
|
||||
* epoch millis; [TaskStatus] goes through the `domain` mappers so that numbering
|
||||
* keeps its single home.
|
||||
*
|
||||
* `PRIORITY` deliberately has no converter — it is stored as the raw iCalendar
|
||||
* integer, because [de.jeanlucmakiola.agendula.domain.Priority] is a lossy
|
||||
* bucketing and a converter would apply it before the value reaches disk.
|
||||
*/
|
||||
object Converters {
|
||||
|
||||
@TypeConverter
|
||||
fun instantToMillis(value: Instant?): Long? = value?.toEpochMilliseconds()
|
||||
|
||||
@TypeConverter
|
||||
fun instantFromMillis(value: Long?): Instant? = value?.let(Instant::fromEpochMilliseconds)
|
||||
|
||||
@TypeConverter
|
||||
fun statusToInt(value: TaskStatus): Int = value.toInt()
|
||||
|
||||
@TypeConverter
|
||||
fun statusFrom(value: Int): TaskStatus = statusFromInt(value)
|
||||
|
||||
@TypeConverter
|
||||
fun alarmReferenceToString(value: AlarmReference): String = value.name
|
||||
|
||||
@TypeConverter
|
||||
fun alarmReferenceFrom(value: String): AlarmReference =
|
||||
runCatching { AlarmReference.valueOf(value) }.getOrDefault(AlarmReference.DUE)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Folds the write-ahead log back into the database file when the app goes to the
|
||||
* background.
|
||||
*
|
||||
* Room runs in WAL mode, and Auto Backup copies files without checkpointing — so
|
||||
* a `-wal` sidecar can hold writes the backed-up `.db` does not. The backup rules
|
||||
* carry all three files, which already makes a restore consistent; this narrows
|
||||
* the window further by ensuring the `.db` alone is usually current, which is what
|
||||
* a restore onto a device that drops the sidecars falls back to.
|
||||
*/
|
||||
@Singleton
|
||||
class DatabaseCheckpoint @Inject constructor(
|
||||
private val database: TasksDatabase,
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
) : DefaultLifecycleObserver {
|
||||
|
||||
override fun onStop(owner: LifecycleOwner) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
database.openHelper.writableDatabase.query("PRAGMA wal_checkpoint(TRUNCATE)").close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* A CalDAV account. Empty until sync lands (`docs/SYNC.md` phase 2), but the FK
|
||||
* from [TaskListEntity] exists from v1 so turning sync on never needs a
|
||||
* migration. The app password is never stored here — Keystore only.
|
||||
*/
|
||||
@Entity(tableName = "accounts")
|
||||
data class AccountEntity(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
@ColumnInfo(name = "id") val id: Long = 0,
|
||||
@ColumnInfo(name = "display_name") val displayName: String,
|
||||
@ColumnInfo(name = "principal_url") val principalUrl: String? = null,
|
||||
@ColumnInfo(name = "home_set_url") val homeSetUrl: String? = null,
|
||||
@ColumnInfo(name = "username") val username: String? = null,
|
||||
@ColumnInfo(name = "last_sync_at") val lastSyncAt: Instant? = null,
|
||||
@ColumnInfo(name = "last_sync_error") val lastSyncError: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* A task list. [accountId] is nullable: `NULL` is a device-only list, and
|
||||
* attaching one to an account later is a plain `UPDATE` rather than a data
|
||||
* migration.
|
||||
*
|
||||
* Deleting an account detaches its lists (`SET NULL`) instead of deleting them,
|
||||
* for the same reason [TaskEntity.parentId] does — removing an account is not
|
||||
* an instruction to destroy the tasks it held.
|
||||
*/
|
||||
@Entity(
|
||||
tableName = "task_lists",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = AccountEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["account_id"],
|
||||
onDelete = ForeignKey.SET_NULL,
|
||||
),
|
||||
],
|
||||
indices = [Index(value = ["account_id"])],
|
||||
)
|
||||
data class TaskListEntity(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
@ColumnInfo(name = "id") val id: Long = 0,
|
||||
@ColumnInfo(name = "name") val name: String,
|
||||
/** ARGB. */
|
||||
@ColumnInfo(name = "color") val color: Int,
|
||||
@ColumnInfo(name = "account_id") val accountId: Long? = null,
|
||||
@ColumnInfo(name = "is_visible", defaultValue = "1") val isVisible: Boolean = true,
|
||||
@ColumnInfo(name = "is_synced", defaultValue = "1") val isSynced: Boolean = true,
|
||||
/** CalDAV owner display name. */
|
||||
@ColumnInfo(name = "owner") val owner: String? = null,
|
||||
@ColumnInfo(name = "is_read_only", defaultValue = "0") val isReadOnly: Boolean = false,
|
||||
/** User ordering. */
|
||||
@ColumnInfo(name = "sort_order", defaultValue = "0") val sortOrder: Int = 0,
|
||||
/** Collection URL, relative to the account root. */
|
||||
@ColumnInfo(name = "href") val href: String? = null,
|
||||
@ColumnInfo(name = "ctag") val ctag: String? = null,
|
||||
/** RFC 6578 sync token, per collection. */
|
||||
@ColumnInfo(name = "sync_token") val syncToken: String? = null,
|
||||
@ColumnInfo(name = "is_dirty", defaultValue = "0") val isDirty: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* A task. Series masters *and* `RECURRENCE-ID` overrides live in this table; an
|
||||
* override is a row with [recurrenceId] set and [masterId] pointing at its
|
||||
* master, sharing the master's [uid].
|
||||
*
|
||||
* [masterId] and [parentId] are different things: [parentId] is task hierarchy
|
||||
* (`RELATED-TO;RELTYPE=PARENT`), [masterId] is recurrence. A row can carry both.
|
||||
*/
|
||||
@Entity(
|
||||
tableName = "tasks",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = TaskListEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["list_id"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
// Deleting a series takes its overrides with it — they would otherwise be
|
||||
// unreachable rows that still sync.
|
||||
ForeignKey(
|
||||
entity = TaskEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["master_id"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
// Deleting a parent promotes its subtasks to top level rather than
|
||||
// destroying work the user did not ask to lose.
|
||||
ForeignKey(
|
||||
entity = TaskEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["parent_id"],
|
||||
onDelete = ForeignKey.SET_NULL,
|
||||
),
|
||||
],
|
||||
indices = [
|
||||
Index(value = ["list_id", "is_deleted"]),
|
||||
Index(value = ["parent_id"]),
|
||||
Index(value = ["master_id", "recurrence_id"]),
|
||||
Index(value = ["is_dirty"]),
|
||||
// An override shares its master's UID, so (list_id, uid) alone would
|
||||
// reject the very rows recurrence depends on. With recurrence_id NULL on
|
||||
// the master and set on each override this reads as: one master and at
|
||||
// most one override per occurrence, per UID, per list. Note SQLite treats
|
||||
// NULLs as distinct in a unique index, so the master half is a statement
|
||||
// of intent, not an enforced constraint.
|
||||
Index(value = ["list_id", "uid", "recurrence_id"], unique = true),
|
||||
],
|
||||
)
|
||||
data class TaskEntity(
|
||||
// identity
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
@ColumnInfo(name = "id") val id: Long = 0,
|
||||
@ColumnInfo(name = "list_id") val listId: Long,
|
||||
/** RFC 4122 UUID, minted at creation in every mode, synced or not. */
|
||||
@ColumnInfo(name = "uid") val uid: String,
|
||||
@ColumnInfo(name = "href") val href: String? = null,
|
||||
@ColumnInfo(name = "etag") val etag: String? = null,
|
||||
|
||||
// content
|
||||
@ColumnInfo(name = "title") val title: String? = null,
|
||||
@ColumnInfo(name = "description") val description: String? = null,
|
||||
@ColumnInfo(name = "location") val location: String? = null,
|
||||
@ColumnInfo(name = "url") val url: String? = null,
|
||||
/** ARGB override for the list colour. */
|
||||
@ColumnInfo(name = "color") val color: Int? = null,
|
||||
|
||||
// state
|
||||
@ColumnInfo(name = "status", defaultValue = "0") val status: TaskStatus = TaskStatus.NEEDS_ACTION,
|
||||
@ColumnInfo(name = "percent_complete") val percentComplete: Int? = null,
|
||||
@ColumnInfo(name = "completed_at") val completedAt: Instant? = null,
|
||||
/**
|
||||
* Raw iCalendar `PRIORITY`: 0 none, 1 highest, 9 lowest. Stored unbucketed —
|
||||
* [de.jeanlucmakiola.agendula.domain.Priority] folds 1–4 into HIGH, so
|
||||
* converting on the way *in* would rewrite a server's `PRIORITY:3` as `1` and
|
||||
* lose it on the next round-trip. The bucketing belongs to the mapper, which
|
||||
* is where the UI needs it.
|
||||
*/
|
||||
@ColumnInfo(name = "priority", defaultValue = "0") val priority: Int = PRIORITY_NONE,
|
||||
/** RFC 5545 `CLASS`: 0 public, 1 private, 2 confidential. */
|
||||
@ColumnInfo(name = "classification") val classification: Int? = null,
|
||||
|
||||
// time
|
||||
@ColumnInfo(name = "dtstart") val dtstart: Instant? = null,
|
||||
@ColumnInfo(name = "due") val due: Instant? = null,
|
||||
/** RFC 5545 `DURATION`, verbatim. Mutually exclusive with [due]. */
|
||||
@ColumnInfo(name = "duration") val duration: String? = null,
|
||||
@ColumnInfo(name = "is_all_day", defaultValue = "0") val isAllDay: Boolean = false,
|
||||
@ColumnInfo(name = "timezone") val timezone: String? = null,
|
||||
|
||||
// recurrence
|
||||
@ColumnInfo(name = "rrule") val rrule: String? = null,
|
||||
@ColumnInfo(name = "rdate") val rdate: String? = null,
|
||||
@ColumnInfo(name = "exdate") val exdate: String? = null,
|
||||
/** This row's `RECURRENCE-ID` anchor; `NULL` on a master. */
|
||||
@ColumnInfo(name = "recurrence_id") val recurrenceId: Instant? = null,
|
||||
/** The series this row overrides; `NULL` on a master. */
|
||||
@ColumnInfo(name = "master_id") val masterId: Long? = null,
|
||||
|
||||
// hierarchy
|
||||
@ColumnInfo(name = "parent_id") val parentId: Long? = null,
|
||||
@ColumnInfo(name = "sort_order", defaultValue = "0") val sortOrder: Int = 0,
|
||||
|
||||
// audit
|
||||
@ColumnInfo(name = "created_at") val createdAt: Instant? = null,
|
||||
@ColumnInfo(name = "last_modified") val lastModified: Instant? = null,
|
||||
@ColumnInfo(name = "sequence", defaultValue = "0") val sequence: Int = 0,
|
||||
|
||||
// sync
|
||||
@ColumnInfo(name = "is_dirty", defaultValue = "0") val isDirty: Boolean = false,
|
||||
/** Tombstone: deleted locally, still owed to a server. */
|
||||
@ColumnInfo(name = "is_deleted", defaultValue = "0") val isDeleted: Boolean = false,
|
||||
/**
|
||||
* Raw unfolded iCalendar lines of every property we do not model, re-emitted
|
||||
* verbatim on write so a round-trip cannot silently lose a field.
|
||||
*/
|
||||
@ColumnInfo(name = "unknown_properties") val unknownProperties: String? = null,
|
||||
)
|
||||
|
||||
/** What [TaskAlarmEntity.minutesBefore] counts back from. */
|
||||
enum class AlarmReference { DUE, START }
|
||||
|
||||
/** A reminder lead on a task. Positive [minutesBefore] is *before* [reference]. */
|
||||
@Entity(
|
||||
tableName = "task_alarms",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = TaskEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["task_id"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
],
|
||||
indices = [Index(value = ["task_id"])],
|
||||
)
|
||||
data class TaskAlarmEntity(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
@ColumnInfo(name = "id") val id: Long = 0,
|
||||
@ColumnInfo(name = "task_id") val taskId: Long,
|
||||
@ColumnInfo(name = "minutes_before") val minutesBefore: Int,
|
||||
@ColumnInfo(name = "reference", defaultValue = "DUE") val reference: AlarmReference = AlarmReference.DUE,
|
||||
@ColumnInfo(name = "message") val message: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Embedded
|
||||
|
||||
/**
|
||||
* A list plus its account's display name, which the domain
|
||||
* [de.jeanlucmakiola.agendula.domain.TaskList] carries and groups by.
|
||||
* `null` means a device-only list.
|
||||
*/
|
||||
data class TaskListRow(
|
||||
@Embedded val list: TaskListEntity,
|
||||
@ColumnInfo(name = "account_display_name") val accountDisplayName: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* A task plus the three columns of its list the domain
|
||||
* [de.jeanlucmakiola.agendula.domain.Task] carries, so reading a screenful is
|
||||
* one query rather than one per list.
|
||||
*/
|
||||
data class TaskRow(
|
||||
@Embedded val task: TaskEntity,
|
||||
@ColumnInfo(name = "list_name") val listName: String,
|
||||
@ColumnInfo(name = "list_color") val listColor: Int,
|
||||
@ColumnInfo(name = "account_display_name") val accountDisplayName: String?,
|
||||
)
|
||||
@@ -0,0 +1,114 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
|
||||
import de.jeanlucmakiola.agendula.domain.LocalAccount
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.agendula.domain.export.ExportTask
|
||||
import de.jeanlucmakiola.agendula.domain.priorityFromICal
|
||||
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** Account type reported for a list attached to one of ours. */
|
||||
const val CALDAV_ACCOUNT_TYPE = "caldav"
|
||||
|
||||
/** Maps Room rows to domain models. Pure + testable, like [de.jeanlucmakiola.agendula.data.tasks.TaskMapper]. */
|
||||
object RoomTaskMapper {
|
||||
|
||||
fun taskList(row: TaskListRow): TaskList = TaskList(
|
||||
id = row.list.id,
|
||||
name = row.list.name,
|
||||
color = row.list.color,
|
||||
// TaskList.accountName is non-null and the lists screen groups by it, so a
|
||||
// list with no account still has to report something to group under.
|
||||
accountName = row.accountDisplayName ?: LocalAccount.NAME,
|
||||
accountType = if (row.list.accountId == null) LocalAccount.TYPE else CALDAV_ACCOUNT_TYPE,
|
||||
isSynced = row.list.isSynced,
|
||||
isVisible = row.list.isVisible,
|
||||
owner = row.list.owner,
|
||||
)
|
||||
|
||||
/**
|
||||
* One occurrence of [row]. [occurrenceStart] is the occurrence's
|
||||
* `RECURRENCE-ID` anchor and `null` for a task that does not recur;
|
||||
* [start] / [due] are that occurrence's resolved times.
|
||||
*/
|
||||
fun task(
|
||||
row: TaskRow,
|
||||
occurrenceStart: Instant? = null,
|
||||
start: Instant? = row.task.dtstart,
|
||||
due: Instant? = row.task.due,
|
||||
distanceFromCurrent: Int? = null,
|
||||
): Task = Task(
|
||||
taskId = row.task.id,
|
||||
listId = row.task.listId,
|
||||
title = row.task.title.orEmpty(),
|
||||
description = row.task.description,
|
||||
location = row.task.location,
|
||||
url = row.task.url,
|
||||
priority = priorityFromICal(row.task.priority),
|
||||
status = row.task.status,
|
||||
percentComplete = row.task.percentComplete,
|
||||
start = start,
|
||||
due = due,
|
||||
isAllDay = row.task.isAllDay,
|
||||
timeZone = row.task.timezone,
|
||||
completedAt = row.task.completedAt,
|
||||
listColor = row.listColor,
|
||||
taskColor = row.task.color,
|
||||
listName = row.listName,
|
||||
accountName = row.accountDisplayName ?: LocalAccount.NAME,
|
||||
parentId = row.task.parentId,
|
||||
isRecurring = row.task.isRecurring,
|
||||
occurrenceStart = occurrenceStart,
|
||||
distanceFromCurrent = distanceFromCurrent,
|
||||
created = row.task.createdAt,
|
||||
lastModified = row.task.lastModified,
|
||||
)
|
||||
|
||||
fun exportTask(task: TaskEntity): ExportTask = ExportTask(
|
||||
taskId = task.id,
|
||||
uid = task.uid,
|
||||
title = task.title.orEmpty(),
|
||||
description = task.description,
|
||||
location = task.location,
|
||||
url = task.url,
|
||||
priority = priorityFromICal(task.priority),
|
||||
status = task.status,
|
||||
percentComplete = task.percentComplete,
|
||||
start = task.dtstart,
|
||||
due = task.due,
|
||||
isAllDay = task.isAllDay,
|
||||
completedAt = task.completedAt,
|
||||
created = task.createdAt,
|
||||
lastModified = task.lastModified,
|
||||
rrule = task.rrule,
|
||||
rdate = task.rdate,
|
||||
parentId = task.parentId?.takeIf { it > 0 },
|
||||
)
|
||||
}
|
||||
|
||||
/** A row carries a recurrence rule if it has an `RRULE` or an `RDATE`. */
|
||||
val TaskEntity.isRecurring: Boolean
|
||||
get() = !rrule.isNullOrBlank() || !rdate.isNullOrBlank()
|
||||
|
||||
/**
|
||||
* The series anchor: `DTSTART` when present, else `DUE`. A `VTODO` may carry only
|
||||
* a due date, and RFC 5545 then anchors the recurrence on it — matching how the
|
||||
* dmfs provider instantiated the same series.
|
||||
*/
|
||||
val TaskEntity.recurrenceAnchor: Instant?
|
||||
get() = dtstart ?: due
|
||||
|
||||
/** The rule set of this series, or `null` when it does not recur. */
|
||||
fun TaskEntity.recurrenceSpec(): RecurrenceSpec? {
|
||||
if (!isRecurring) return null
|
||||
val anchor = recurrenceAnchor ?: return null
|
||||
return RecurrenceSpec(
|
||||
rrule = rrule,
|
||||
rdate = rdate,
|
||||
exdate = exdate,
|
||||
anchor = anchor,
|
||||
isAllDay = isAllDay,
|
||||
timeZone = timezone,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
|
||||
import androidx.room.InvalidationTracker
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TaskWriteFailedException
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.agendula.domain.export.ExportTask
|
||||
import de.jeanlucmakiola.agendula.domain.recurrence.ExpansionWindow
|
||||
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander
|
||||
import java.time.ZoneId
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** How far either side of now a series is expanded. */
|
||||
private val WINDOW_BACK = 365.days
|
||||
private val WINDOW_FORWARD = 730.days
|
||||
|
||||
private val OBSERVED_TABLES = arrayOf("tasks", "task_lists", "task_alarms", "accounts")
|
||||
|
||||
/**
|
||||
* [TasksDataSource] over Agendula's own Room store.
|
||||
*
|
||||
* The one structural difference from [de.jeanlucmakiola.agendula.data.tasks
|
||||
* .AndroidTasksDataSource]: there is no materialised instances table, so a
|
||||
* recurring series is expanded here, at read time, by [RecurrenceExpander].
|
||||
* Nothing above this cares — the repository already filters and sorts in Kotlin.
|
||||
*/
|
||||
@Singleton
|
||||
class RoomTasksDataSource @Inject constructor(
|
||||
private val database: TasksDatabase,
|
||||
) : TasksDataSource {
|
||||
|
||||
private val clock: Clock = Clock.System
|
||||
|
||||
private val tasks get() = database.tasks()
|
||||
private val lists get() = database.taskLists()
|
||||
private val alarms get() = database.alarms()
|
||||
|
||||
// --- reads ----------------------------------------------------------------
|
||||
|
||||
override fun taskLists(): List<TaskList> = lists.lists().map(RoomTaskMapper::taskList)
|
||||
|
||||
override fun tasks(query: TaskQuery): List<Task> {
|
||||
val now = clock.now()
|
||||
val overrides = tasks.allOverrides(query.listId).groupBy { it.masterId }
|
||||
return tasks.tasks(query.listId, query.includeCompleted)
|
||||
.flatMap { occurrencesOf(it, overrides[it.task.id].orEmpty(), now) }
|
||||
.filter { query.includeCompleted || !it.isClosed }
|
||||
}
|
||||
|
||||
override fun task(taskId: Long): Task? {
|
||||
val row = tasks.task(taskId) ?: return null
|
||||
// An override row is one occurrence in its own right; it names the
|
||||
// occurrence it replaces rather than expanding to a series.
|
||||
row.task.recurrenceId?.let { return RoomTaskMapper.task(row, occurrenceStart = it) }
|
||||
val now = clock.now()
|
||||
val occurrences = occurrencesOf(row, tasks.overrides(taskId), now)
|
||||
return occurrences.firstOrNull { it.distanceFromCurrent == 0 } ?: occurrences.firstOrNull()
|
||||
}
|
||||
|
||||
override fun subtasks(parentTaskId: Long): List<Task> {
|
||||
val now = clock.now()
|
||||
return tasks.subtasks(parentTaskId)
|
||||
.flatMap { occurrencesOf(it, tasks.overrides(it.task.id), now) }
|
||||
}
|
||||
|
||||
override fun exportTasks(listId: Long): List<ExportTask> =
|
||||
tasks.exportTasks(listId).map(RoomTaskMapper::exportTask)
|
||||
|
||||
override fun alarms(): Map<Long, TaskReminder> =
|
||||
alarms.all().associate {
|
||||
it.taskId to TaskReminder(
|
||||
minutesBefore = it.minutesBefore,
|
||||
fromStart = it.reference == AlarmReference.START,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every occurrence of [row] inside the expansion window, with any
|
||||
* `RECURRENCE-ID` override substituted for the occurrence it replaces.
|
||||
*
|
||||
* A non-recurring task is its own single occurrence and carries a null
|
||||
* [Task.occurrenceStart], so it keys and edits by task id exactly as before.
|
||||
*/
|
||||
private fun occurrencesOf(row: TaskRow, overrides: List<TaskEntity>, now: Instant): List<Task> {
|
||||
val spec = row.task.recurrenceSpec() ?: return listOf(RoomTaskMapper.task(row))
|
||||
val window = ExpansionWindow(
|
||||
from = now - WINDOW_BACK,
|
||||
until = now + WINDOW_FORWARD,
|
||||
pivot = now,
|
||||
)
|
||||
val anchors = RecurrenceExpander.expand(spec, window)
|
||||
if (anchors.isEmpty()) return emptyList()
|
||||
|
||||
val distances = RecurrenceExpander.distancesFromCurrent(anchors, now)
|
||||
val byAnchor = overrides.associateBy { it.recurrenceId }
|
||||
|
||||
return anchors.mapIndexedNotNull { index, anchor ->
|
||||
val override = byAnchor[anchor]
|
||||
if (override != null) {
|
||||
RoomTaskMapper.task(
|
||||
row = row.copy(task = override),
|
||||
occurrenceStart = anchor,
|
||||
start = override.dtstart,
|
||||
due = override.due,
|
||||
distanceFromCurrent = distances[index],
|
||||
)
|
||||
} else {
|
||||
val (start, due) = occurrenceTimes(row.task, anchor)
|
||||
RoomTaskMapper.task(
|
||||
row = row,
|
||||
occurrenceStart = anchor,
|
||||
start = start,
|
||||
due = due,
|
||||
distanceFromCurrent = distances[index],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One occurrence's resolved start and due. A timed series keeps each
|
||||
* occurrence's duration; a due-anchored one has no start to offset from, so
|
||||
* the anchor *is* the due date.
|
||||
*/
|
||||
private fun occurrenceTimes(master: TaskEntity, anchor: Instant): Pair<Instant?, Instant?> {
|
||||
if (master.dtstart == null) return null to anchor
|
||||
val length = master.due?.let { it - master.dtstart }
|
||||
return anchor to length?.let { anchor + it }
|
||||
}
|
||||
|
||||
// --- writes ---------------------------------------------------------------
|
||||
|
||||
override fun insertTask(form: TaskForm): Long {
|
||||
if (lists.exists(form.listId) == 0) throw TaskWriteFailedException("insert task: no list ${form.listId}")
|
||||
val entity = TaskFormWriter.newTask(form, uid = UUID.randomUUID().toString(), now = clock.now(), tzId = zone())
|
||||
return tasks.insert(entity)
|
||||
}
|
||||
|
||||
override fun updateTask(taskId: Long, form: TaskForm) {
|
||||
val current = tasks.entity(taskId) ?: throw TaskWriteFailedException("update task $taskId")
|
||||
tasks.update(TaskFormWriter.apply(current, form, clock.now(), zone()))
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes one occurrence as a `RECURRENCE-ID` override — RFC 5545's model, and
|
||||
* what every other CalDAV client expects to receive. The dmfs provider
|
||||
* detached the occurrence into a brand-new task with its own UID instead,
|
||||
* which is the model least compatible with sync; the override shares its
|
||||
* master's UID, which is exactly what makes it an override.
|
||||
*/
|
||||
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) {
|
||||
val master = tasks.entity(taskId) ?: throw TaskWriteFailedException("update instance $taskId")
|
||||
val now = clock.now()
|
||||
val existing = tasks.override(taskId, occurrenceStart)
|
||||
if (existing != null) {
|
||||
tasks.update(TaskFormWriter.apply(existing, form, now, zone()))
|
||||
return
|
||||
}
|
||||
val (start, due) = occurrenceTimes(master, occurrenceStart)
|
||||
val fork = TaskFormWriter.apply(
|
||||
newOverride(master, taskId, occurrenceStart, start, due),
|
||||
form,
|
||||
now,
|
||||
zone(),
|
||||
)
|
||||
// The list and parent come from the master: moving one occurrence between
|
||||
// lists or parents is not something the override model expresses.
|
||||
val id = tasks.insert(fork.copy(listId = master.listId, parentId = master.parentId))
|
||||
alarms.forTask(taskId).firstOrNull()?.let { alarms.replaceForTask(id, it) }
|
||||
}
|
||||
|
||||
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) {
|
||||
alarms.replaceForTask(
|
||||
taskId,
|
||||
minutesBeforeDue?.let { TaskAlarmEntity(taskId = taskId, minutesBefore = it) },
|
||||
)
|
||||
}
|
||||
|
||||
override fun setCompleted(taskId: Long, completed: Boolean) {
|
||||
val current = tasks.entity(taskId) ?: throw TaskWriteFailedException("complete task $taskId")
|
||||
tasks.update(TaskFormWriter.completed(current, completed, clock.now()))
|
||||
}
|
||||
|
||||
/**
|
||||
* Ticking one occurrence forks a `RECURRENCE-ID` override carrying the
|
||||
* completion — the same model [updateInstance] writes. Writing the status onto
|
||||
* the master instead would close the series: the master is what
|
||||
* [TaskDao.tasks] filters on, so every occurrence, past and future, would
|
||||
* leave every list at once.
|
||||
*/
|
||||
override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) {
|
||||
val master = tasks.entity(taskId) ?: throw TaskWriteFailedException("complete instance $taskId")
|
||||
// Not a series master — an override, or a plain task the caller handed an
|
||||
// anchor for. Either way this row *is* the occurrence.
|
||||
if (master.recurrenceSpec() == null) return setCompleted(taskId, completed)
|
||||
|
||||
val now = clock.now()
|
||||
tasks.override(taskId, occurrenceStart)?.let {
|
||||
tasks.update(TaskFormWriter.completed(it, completed, now))
|
||||
return
|
||||
}
|
||||
val (start, due) = occurrenceTimes(master, occurrenceStart)
|
||||
val fork = TaskFormWriter.completed(newOverride(master, taskId, occurrenceStart, start, due), completed, now)
|
||||
val id = tasks.insert(fork)
|
||||
alarms.forTask(taskId).firstOrNull()?.let { alarms.replaceForTask(id, it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* A blank override row for one occurrence of [master]: same UID (that is what
|
||||
* makes it an override rather than a separate task), the series fields
|
||||
* stripped, and no `href`/`etag` because the server has never seen it.
|
||||
*/
|
||||
private fun newOverride(
|
||||
master: TaskEntity,
|
||||
masterId: Long,
|
||||
occurrenceStart: Instant,
|
||||
start: Instant?,
|
||||
due: Instant?,
|
||||
): TaskEntity = master.copy(
|
||||
id = 0,
|
||||
masterId = masterId,
|
||||
recurrenceId = occurrenceStart,
|
||||
dtstart = start,
|
||||
due = due,
|
||||
rrule = null,
|
||||
rdate = null,
|
||||
exdate = null,
|
||||
href = null,
|
||||
etag = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Hard delete for a row no server knows about, tombstone for one that is
|
||||
* still owed to a collection. `master_id` cascades, so deleting a series
|
||||
* takes its overrides with it.
|
||||
*/
|
||||
override fun deleteTask(taskId: Long) {
|
||||
val current = tasks.entity(taskId) ?: return
|
||||
val listAccount = lists.entity(current.listId)?.accountId
|
||||
if (listAccount == null) tasks.delete(taskId) else tasks.markDeleted(taskId, clock.now())
|
||||
}
|
||||
|
||||
override fun createLocalList(name: String, color: Int): Long =
|
||||
lists.insert(TaskListEntity(name = name.trim(), color = color))
|
||||
|
||||
override fun updateList(listId: Long, name: String, color: Int) {
|
||||
val current = lists.entity(listId) ?: throw TaskWriteFailedException("update list $listId")
|
||||
// Only an account-backed collection owes a server a PROPPATCH; a
|
||||
// device-only list has nothing to be dirty for.
|
||||
lists.update(
|
||||
current.copy(
|
||||
name = name.trim(),
|
||||
color = color,
|
||||
isDirty = current.accountId != null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** `tasks.list_id` is `ON DELETE CASCADE`, so the list's tasks go with it. */
|
||||
override fun deleteList(listId: Long) = lists.delete(listId)
|
||||
|
||||
// --- observation ----------------------------------------------------------
|
||||
|
||||
override fun registerObserver(onChange: () -> Unit): AutoCloseable {
|
||||
val observer = object : InvalidationTracker.Observer(OBSERVED_TABLES) {
|
||||
override fun onInvalidated(tables: Set<String>) = onChange()
|
||||
}
|
||||
database.invalidationTracker.addObserver(observer)
|
||||
return AutoCloseable { database.invalidationTracker.removeObserver(observer) }
|
||||
}
|
||||
|
||||
private fun zone(): String = ZoneId.systemDefault().id
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
|
||||
/** Reads and writes over `task_alarms`. */
|
||||
@Dao
|
||||
interface TaskAlarmDao {
|
||||
|
||||
/** Every reminder in the store, for one scheduler pass. */
|
||||
@Query("SELECT * FROM task_alarms")
|
||||
fun all(): List<TaskAlarmEntity>
|
||||
|
||||
@Query("SELECT * FROM task_alarms WHERE task_id = :taskId")
|
||||
fun forTask(taskId: Long): List<TaskAlarmEntity>
|
||||
|
||||
@Insert
|
||||
fun insert(alarm: TaskAlarmEntity): Long
|
||||
|
||||
@Query("DELETE FROM task_alarms WHERE task_id = :taskId")
|
||||
fun deleteForTask(taskId: Long): Int
|
||||
|
||||
/**
|
||||
* Set the task's only reminder, or clear it with `null`. The row that lands is
|
||||
* always a new one — the id is cleared so an alarm lifted off another task
|
||||
* (forking an occurrence copies the master's) inserts instead of colliding.
|
||||
*/
|
||||
@Transaction
|
||||
fun replaceForTask(taskId: Long, alarm: TaskAlarmEntity?) {
|
||||
deleteForTask(taskId)
|
||||
alarm?.let { insert(it.copy(id = 0, taskId = taskId)) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
import androidx.room.Update
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Reads and writes over `tasks`.
|
||||
*
|
||||
* Reads split masters from overrides on purpose: [tasks] returns the rows a
|
||||
* recurrence expander expands (a non-recurring task is its own single
|
||||
* occurrence), and [allOverrides] / [overrides] return the
|
||||
* `RECURRENCE-ID` rows that replace individual occurrences. Nothing here
|
||||
* expands anything — that is phase 2's job, in Kotlin.
|
||||
*/
|
||||
@Dao
|
||||
interface TaskDao {
|
||||
|
||||
// --- reads ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Master (and non-recurring) rows, optionally narrowed to one list. Closed
|
||||
* tasks — `COMPLETED` and `CANCELLED` — are excluded unless
|
||||
* [includeCompleted]; tombstones always are.
|
||||
*/
|
||||
@Query(
|
||||
"""
|
||||
SELECT t.*, l.name AS list_name, l.color AS list_color,
|
||||
a.display_name AS account_display_name
|
||||
FROM tasks t
|
||||
JOIN task_lists l ON l.id = t.list_id
|
||||
LEFT JOIN accounts a ON a.id = l.account_id
|
||||
WHERE t.is_deleted = 0
|
||||
AND t.master_id IS NULL
|
||||
AND (:listId IS NULL OR t.list_id = :listId)
|
||||
AND (:includeCompleted = 1 OR t.status NOT IN (2, 3))
|
||||
"""
|
||||
)
|
||||
fun tasks(listId: Long?, includeCompleted: Boolean): List<TaskRow>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT t.*, l.name AS list_name, l.color AS list_color,
|
||||
a.display_name AS account_display_name
|
||||
FROM tasks t
|
||||
JOIN task_lists l ON l.id = t.list_id
|
||||
LEFT JOIN accounts a ON a.id = l.account_id
|
||||
WHERE t.id = :taskId AND t.is_deleted = 0
|
||||
"""
|
||||
)
|
||||
fun task(taskId: Long): TaskRow?
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT t.*, l.name AS list_name, l.color AS list_color,
|
||||
a.display_name AS account_display_name
|
||||
FROM tasks t
|
||||
JOIN task_lists l ON l.id = t.list_id
|
||||
LEFT JOIN accounts a ON a.id = l.account_id
|
||||
WHERE t.parent_id = :parentTaskId AND t.is_deleted = 0 AND t.master_id IS NULL
|
||||
"""
|
||||
)
|
||||
fun subtasks(parentTaskId: Long): List<TaskRow>
|
||||
|
||||
@Query("SELECT * FROM tasks WHERE id = :taskId")
|
||||
fun entity(taskId: Long): TaskEntity?
|
||||
|
||||
/**
|
||||
* Every override, optionally narrowed to one list — read alongside [tasks] so
|
||||
* expansion can replace the occurrences they override in one pass rather than
|
||||
* querying per series.
|
||||
*/
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM tasks
|
||||
WHERE master_id IS NOT NULL AND is_deleted = 0
|
||||
AND (:listId IS NULL OR list_id = :listId)
|
||||
"""
|
||||
)
|
||||
fun allOverrides(listId: Long?): List<TaskEntity>
|
||||
|
||||
@Query("SELECT * FROM tasks WHERE master_id = :masterId AND is_deleted = 0")
|
||||
fun overrides(masterId: Long): List<TaskEntity>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM tasks WHERE master_id = :masterId AND recurrence_id IS :recurrenceId AND is_deleted = 0"
|
||||
)
|
||||
fun override(masterId: Long, recurrenceId: Instant?): TaskEntity?
|
||||
|
||||
@Query("SELECT * FROM tasks WHERE list_id = :listId AND uid = :uid AND recurrence_id IS :recurrenceId")
|
||||
fun byUid(listId: Long, uid: String, recurrenceId: Instant? = null): TaskEntity?
|
||||
|
||||
/** Masters only, tombstones excluded — what an `.ics` export writes. */
|
||||
@Query("SELECT * FROM tasks WHERE list_id = :listId AND is_deleted = 0 AND master_id IS NULL")
|
||||
fun exportTasks(listId: Long): List<TaskEntity>
|
||||
|
||||
@Query("SELECT * FROM tasks WHERE is_dirty = 1")
|
||||
fun dirty(): List<TaskEntity>
|
||||
|
||||
// --- writes ---------------------------------------------------------------
|
||||
|
||||
@Insert
|
||||
fun insert(task: TaskEntity): Long
|
||||
|
||||
@Update
|
||||
fun update(task: TaskEntity): Int
|
||||
|
||||
@Query(
|
||||
"""
|
||||
UPDATE tasks SET status = :status, percent_complete = :percentComplete,
|
||||
completed_at = :completedAt, last_modified = :lastModified, is_dirty = :dirty
|
||||
WHERE id = :taskId
|
||||
"""
|
||||
)
|
||||
fun setCompletion(
|
||||
taskId: Long,
|
||||
status: TaskStatus,
|
||||
percentComplete: Int?,
|
||||
completedAt: Instant?,
|
||||
lastModified: Instant?,
|
||||
dirty: Boolean,
|
||||
): Int
|
||||
|
||||
/** Hard delete. Used when the row was never on a server. */
|
||||
@Query("DELETE FROM tasks WHERE id = :taskId")
|
||||
fun delete(taskId: Long): Int
|
||||
|
||||
/** Tombstone, for a row a server still knows about. */
|
||||
@Query("UPDATE tasks SET is_deleted = 1, is_dirty = 1, last_modified = :at WHERE id = :taskId")
|
||||
fun markDeleted(taskId: Long, at: Instant?): Int
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import de.jeanlucmakiola.agendula.domain.toICal
|
||||
import kotlin.time.Instant
|
||||
|
||||
private const val MILLIS_PER_DAY = 24L * 60 * 60 * 1000
|
||||
|
||||
/** Floor to UTC midnight when [allDay], else pass through unchanged. */
|
||||
internal fun Instant.forAllDay(allDay: Boolean): Instant =
|
||||
if (!allDay) this
|
||||
else Instant.fromEpochMilliseconds(
|
||||
Math.floorDiv(toEpochMilliseconds(), MILLIS_PER_DAY) * MILLIS_PER_DAY,
|
||||
)
|
||||
|
||||
/**
|
||||
* Applies a [TaskForm] to a [TaskEntity]. Pure, so the semantics below are
|
||||
* testable on the JVM without a database.
|
||||
*
|
||||
* This is the Room counterpart of
|
||||
* [de.jeanlucmakiola.agendula.data.tasks.TaskWriteMapper], which stays for
|
||||
* External mode. It is a separate object rather than a shared one because most
|
||||
* of what that mapper does is work around the provider — clearing `DURATION`
|
||||
* because the provider validates a merged row, writing `STATUS` both ways
|
||||
* because the provider auto-completes at 100% but will not reopen below it. Here
|
||||
* those rules are ours to state directly.
|
||||
*/
|
||||
object TaskFormWriter {
|
||||
|
||||
/** A brand-new task. [uid] is minted by the caller and never null. */
|
||||
fun newTask(form: TaskForm, uid: String, now: Instant, tzId: String): TaskEntity =
|
||||
apply(
|
||||
TaskEntity(listId = form.listId, uid = uid, createdAt = now),
|
||||
form,
|
||||
now,
|
||||
tzId,
|
||||
)
|
||||
|
||||
/** [current] with [form] applied. Identity, recurrence and sync columns are left alone. */
|
||||
fun apply(current: TaskEntity, form: TaskForm, now: Instant, tzId: String): TaskEntity {
|
||||
val percent = form.percentComplete?.coerceIn(0, 100)
|
||||
val timed = !form.isAllDay && (form.start != null || form.due != null)
|
||||
return current.copy(
|
||||
listId = form.listId,
|
||||
title = form.title.trim(),
|
||||
description = form.description?.trim()?.ifBlank { null },
|
||||
priority = form.priority.toICal(),
|
||||
percentComplete = percent,
|
||||
status = statusFor(percent, current.status),
|
||||
completedAt = completedAtFor(percent, current, now),
|
||||
dtstart = form.start?.forAllDay(form.isAllDay),
|
||||
due = form.due?.forAllDay(form.isAllDay),
|
||||
// DUE and DURATION are mutually exclusive (RFC 5545 §3.6.2).
|
||||
duration = null,
|
||||
isAllDay = form.isAllDay,
|
||||
timezone = if (timed) tzId else null,
|
||||
parentId = form.parentId?.takeIf { it > 0 },
|
||||
lastModified = now,
|
||||
isDirty = true,
|
||||
)
|
||||
}
|
||||
|
||||
/** The completion triple, for the standalone complete toggle. */
|
||||
fun completed(current: TaskEntity, completed: Boolean, now: Instant): TaskEntity = current.copy(
|
||||
status = if (completed) TaskStatus.COMPLETED else TaskStatus.NEEDS_ACTION,
|
||||
percentComplete = if (completed) 100 else null,
|
||||
completedAt = if (completed) now else null,
|
||||
lastModified = now,
|
||||
isDirty = true,
|
||||
)
|
||||
|
||||
/**
|
||||
* A form carrying no percent leaves status alone — the standalone toggle stays
|
||||
* authoritative. Otherwise progress and status move together in both
|
||||
* directions, which is the asymmetry the provider never had: it auto-completed
|
||||
* at 100% but would not reopen below it, stranding a task "done at 75%".
|
||||
*/
|
||||
private fun statusFor(percent: Int?, current: TaskStatus): TaskStatus = when {
|
||||
percent == null -> current
|
||||
percent >= 100 -> TaskStatus.COMPLETED
|
||||
percent > 0 -> TaskStatus.IN_PROCESS
|
||||
else -> TaskStatus.NEEDS_ACTION
|
||||
}
|
||||
|
||||
private fun completedAtFor(percent: Int?, current: TaskEntity, now: Instant): Instant? = when {
|
||||
percent == null -> current.completedAt
|
||||
percent >= 100 -> current.completedAt ?: now
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
import androidx.room.Update
|
||||
|
||||
/** Reads and writes over `task_lists`. Synchronous, like the seam above it. */
|
||||
@Dao
|
||||
interface TaskListDao {
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT l.*, a.display_name AS account_display_name
|
||||
FROM task_lists l LEFT JOIN accounts a ON a.id = l.account_id
|
||||
ORDER BY a.display_name, l.sort_order, l.name
|
||||
"""
|
||||
)
|
||||
fun lists(): List<TaskListRow>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT l.*, a.display_name AS account_display_name
|
||||
FROM task_lists l LEFT JOIN accounts a ON a.id = l.account_id
|
||||
WHERE l.id = :listId
|
||||
"""
|
||||
)
|
||||
fun list(listId: Long): TaskListRow?
|
||||
|
||||
@Query("SELECT * FROM task_lists WHERE id = :listId")
|
||||
fun entity(listId: Long): TaskListEntity?
|
||||
|
||||
@Query("SELECT COUNT(*) FROM task_lists WHERE id = :listId")
|
||||
fun exists(listId: Long): Int
|
||||
|
||||
@Insert
|
||||
fun insert(list: TaskListEntity): Long
|
||||
|
||||
@Update
|
||||
fun update(list: TaskListEntity)
|
||||
|
||||
@Query("UPDATE task_lists SET is_visible = :visible WHERE id = :listId")
|
||||
fun setVisible(listId: Long, visible: Boolean)
|
||||
|
||||
/** Attach a list to an account, or detach it with `null`. */
|
||||
@Query("UPDATE task_lists SET account_id = :accountId WHERE id = :listId")
|
||||
fun setAccount(listId: Long, accountId: Long?)
|
||||
|
||||
@Query("DELETE FROM task_lists WHERE id = :listId")
|
||||
fun delete(listId: Long)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
|
||||
/**
|
||||
* Agendula's own task store (`docs/OWN-STORE.md`). Four tables, designed from
|
||||
* what the app actually reads and writes plus RFC 5545's `VTODO`.
|
||||
*
|
||||
* Schemas are exported to `app/schemas/` and committed, so a future version can
|
||||
* be migration-tested against this one.
|
||||
*/
|
||||
@Database(
|
||||
entities = [
|
||||
AccountEntity::class,
|
||||
TaskListEntity::class,
|
||||
TaskEntity::class,
|
||||
TaskAlarmEntity::class,
|
||||
],
|
||||
version = 1,
|
||||
exportSchema = true,
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
abstract class TasksDatabase : RoomDatabase() {
|
||||
abstract fun taskLists(): TaskListDao
|
||||
abstract fun tasks(): TaskDao
|
||||
abstract fun alarms(): TaskAlarmDao
|
||||
abstract fun accounts(): AccountDao
|
||||
|
||||
companion object {
|
||||
const val NAME = "agendula-tasks.db"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package de.jeanlucmakiola.agendula.domain
|
||||
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* All-day tasks are date-only in iCalendar. OpenTasks reads them back through
|
||||
* `DateTime.toAllDay()`, which discards the time-of-day and resolves the
|
||||
* remaining date against UTC — so the storage convention is **UTC midnight of
|
||||
* the intended calendar date, with a null timezone**. Timed tasks, by contrast,
|
||||
* are ordinary instants rendered in the device's zone.
|
||||
*
|
||||
* These two conventions disagree about which day a given instant is, which is
|
||||
* why every all-day value needs an explicit conversion rather than a raw
|
||||
* `Instant` passed straight through.
|
||||
*/
|
||||
|
||||
/** UTC midnight of [date] — the storage form for an all-day value. */
|
||||
fun allDayInstantOf(date: java.time.LocalDate): Instant =
|
||||
Instant.fromEpochMilliseconds(date.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli())
|
||||
|
||||
/**
|
||||
* The calendar date this instant denotes: read in UTC for [allDay] values,
|
||||
* in [zone] for timed ones.
|
||||
*/
|
||||
fun Instant.calendarDate(allDay: Boolean, zone: ZoneId = ZoneId.systemDefault()): java.time.LocalDate =
|
||||
java.time.Instant.ofEpochMilli(toEpochMilliseconds())
|
||||
.atZone(if (allDay) ZoneOffset.UTC else zone)
|
||||
.toLocalDate()
|
||||
|
||||
/**
|
||||
* Move an instant across the two conventions when the all-day switch flips, so
|
||||
* the day the user is looking at stays put. Without this, toggling all-day off
|
||||
* turns a UTC-midnight value into "02:00" in Berlin (or the previous day, 19:00,
|
||||
* in New York) — reading to the user as "the time reset itself".
|
||||
*/
|
||||
fun Instant.rebasedForAllDay(allDay: Boolean, zone: ZoneId = ZoneId.systemDefault()): Instant =
|
||||
if (allDay) allDayInstantOf(calendarDate(allDay = false, zone = zone))
|
||||
else Instant.fromEpochMilliseconds(
|
||||
calendarDate(allDay = true).atStartOfDay(zone).toInstant().toEpochMilli(),
|
||||
)
|
||||
@@ -1,6 +1,5 @@
|
||||
package de.jeanlucmakiola.agendula.domain
|
||||
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** A task list (the `tasklists` table). Lists group under their account. */
|
||||
@@ -15,7 +14,8 @@ data class TaskList(
|
||||
val owner: String?,
|
||||
) {
|
||||
/** A device-only list Agendula (or another app) created locally, not synced. */
|
||||
val isLocal: Boolean get() = accountType == TasksContract.LOCAL_ACCOUNT_TYPE
|
||||
val isLocal: Boolean
|
||||
get() = accountType == LocalAccount.TYPE || accountType == LocalAccount.DMFS_TYPE
|
||||
}
|
||||
|
||||
enum class TaskStatus { NEEDS_ACTION, IN_PROCESS, COMPLETED, CANCELLED }
|
||||
@@ -24,11 +24,11 @@ enum class TaskStatus { NEEDS_ACTION, IN_PROCESS, COMPLETED, CANCELLED }
|
||||
enum class Priority { NONE, LOW, MEDIUM, HIGH }
|
||||
|
||||
/**
|
||||
* A task occurrence as read from the `instances` view. [id] is the instance row
|
||||
* id; [taskId] is the underlying `tasks._id` and the stable target for edits.
|
||||
* One occurrence of a task. [taskId] is the underlying task row and the stable
|
||||
* target for edits and navigation; [occurrenceStart] distinguishes occurrences of
|
||||
* the same series.
|
||||
*/
|
||||
data class Task(
|
||||
val id: Long,
|
||||
val taskId: Long,
|
||||
val listId: Long,
|
||||
val title: String,
|
||||
@@ -48,7 +48,22 @@ data class Task(
|
||||
val listName: String?,
|
||||
val accountName: String?,
|
||||
val parentId: Long?,
|
||||
/**
|
||||
* This row carries a recurrence rule, so it is one occurrence of a series and
|
||||
* [start]/[due] are that occurrence's resolved times — *not* the master's
|
||||
* anchor. Edits go through
|
||||
* [de.jeanlucmakiola.agendula.data.tasks.TasksDataSource.updateInstance], which
|
||||
* forks a `RECURRENCE-ID` override instead of re-anchoring the series.
|
||||
*/
|
||||
val isRecurring: Boolean,
|
||||
/**
|
||||
* This occurrence's `RECURRENCE-ID` anchor — what identifies it within its
|
||||
* series — or `null` when the task does not recur. Together with [taskId] it
|
||||
* is a stable, collision-free identity for an occurrence, which is what list
|
||||
* keys and [de.jeanlucmakiola.agendula.data.tasks.TasksDataSource.updateInstance]
|
||||
* address it by.
|
||||
*/
|
||||
val occurrenceStart: Instant? = null,
|
||||
val distanceFromCurrent: Int?,
|
||||
val created: Instant?,
|
||||
val lastModified: Instant?,
|
||||
@@ -65,6 +80,15 @@ data class Task(
|
||||
val isSubtask: Boolean get() = parentId != null && parentId > 0
|
||||
/** The task's own colour if set, else the list colour. */
|
||||
val effectiveColor: Int get() = taskColor ?: listColor
|
||||
|
||||
/**
|
||||
* Stable identity for a lazy-list key. Two occurrences of one series can show
|
||||
* up in the same list, so [taskId] alone is not unique — and folding
|
||||
* `(taskId, occurrenceStart)` into a Long could collide, which as a Compose
|
||||
* key is a visible bug.
|
||||
*/
|
||||
val occurrenceKey: String
|
||||
get() = if (occurrenceStart == null) "$taskId" else "$taskId@${occurrenceStart.toEpochMilliseconds()}"
|
||||
}
|
||||
|
||||
/** Detail bundle: a task, its parent (if it's a subtask), and its direct children. */
|
||||
@@ -85,22 +109,22 @@ fun priorityFromICal(value: Int?): Priority = when {
|
||||
|
||||
/** Representative iCalendar priority for a bucket (1 high, 5 medium, 9 low). */
|
||||
fun Priority.toICal(): Int = when (this) {
|
||||
Priority.NONE -> TasksContract.PRIORITY_NONE
|
||||
Priority.NONE -> PRIORITY_NONE
|
||||
Priority.HIGH -> 1
|
||||
Priority.MEDIUM -> 5
|
||||
Priority.LOW -> 9
|
||||
}
|
||||
|
||||
fun statusFromInt(value: Int?): TaskStatus = when (value) {
|
||||
TasksContract.STATUS_IN_PROCESS -> TaskStatus.IN_PROCESS
|
||||
TasksContract.STATUS_COMPLETED -> TaskStatus.COMPLETED
|
||||
TasksContract.STATUS_CANCELLED -> TaskStatus.CANCELLED
|
||||
ICalStatus.IN_PROCESS -> TaskStatus.IN_PROCESS
|
||||
ICalStatus.COMPLETED -> TaskStatus.COMPLETED
|
||||
ICalStatus.CANCELLED -> TaskStatus.CANCELLED
|
||||
else -> TaskStatus.NEEDS_ACTION
|
||||
}
|
||||
|
||||
fun TaskStatus.toInt(): Int = when (this) {
|
||||
TaskStatus.NEEDS_ACTION -> TasksContract.STATUS_NEEDS_ACTION
|
||||
TaskStatus.IN_PROCESS -> TasksContract.STATUS_IN_PROCESS
|
||||
TaskStatus.COMPLETED -> TasksContract.STATUS_COMPLETED
|
||||
TaskStatus.CANCELLED -> TasksContract.STATUS_CANCELLED
|
||||
TaskStatus.NEEDS_ACTION -> ICalStatus.NEEDS_ACTION
|
||||
TaskStatus.IN_PROCESS -> ICalStatus.IN_PROCESS
|
||||
TaskStatus.COMPLETED -> ICalStatus.COMPLETED
|
||||
TaskStatus.CANCELLED -> ICalStatus.CANCELLED
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package de.jeanlucmakiola.agendula.domain
|
||||
|
||||
/**
|
||||
* iCalendar `STATUS` values for a `VTODO`, as integers.
|
||||
*
|
||||
* These live in `domain` rather than being read out of a provider contract: the
|
||||
* numbering is Agendula's own storage encoding as much as it is dmfs's, and the
|
||||
* domain layer must not depend on the data layer to map its own enums.
|
||||
*/
|
||||
object ICalStatus {
|
||||
const val NEEDS_ACTION = 0
|
||||
const val IN_PROCESS = 1
|
||||
const val COMPLETED = 2
|
||||
const val CANCELLED = 3
|
||||
}
|
||||
|
||||
/** Priority 0 means "no priority"; 1 is highest, 9 lowest (RFC 5545 §3.8.1.9). */
|
||||
const val PRIORITY_NONE = 0
|
||||
|
||||
/** How a device-only list identifies its (non-existent) account. */
|
||||
object LocalAccount {
|
||||
/** Shown as the section header above device-only lists. */
|
||||
const val NAME = "Local"
|
||||
|
||||
/** What Agendula's own store reports for a list with no account. */
|
||||
const val TYPE = "local"
|
||||
|
||||
/** What a dmfs-derived provider reports in External mode. */
|
||||
const val DMFS_TYPE = "org.dmfs.account.LOCAL"
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package de.jeanlucmakiola.agendula.domain.export
|
||||
|
||||
import de.jeanlucmakiola.agendula.domain.Priority
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* One task as it goes out to iCalendar — a **master** task, not an occurrence.
|
||||
*
|
||||
* Deliberately not [de.jeanlucmakiola.agendula.domain.Task]. That model is read
|
||||
* from the `instances` view, where a recurring task appears once per occurrence
|
||||
* with resolved times and no rule; exporting from it would write the same task
|
||||
* fifty times and lose the RRULE that generated them. Export reads the `tasks`
|
||||
* table instead, and needs two fields the UI never asks for ([uid], [rrule]).
|
||||
*/
|
||||
data class ExportTask(
|
||||
/** `tasks._id` — the fallback identity when [uid] is absent. */
|
||||
val taskId: Long,
|
||||
/**
|
||||
* The iCalendar UID, or `null` for a task created on this device and never
|
||||
* synced. The dmfs provider only lets a *sync adapter* assign one, so in Local
|
||||
* mode this is null for everything — see [ICalendarWriter.uidFor], which
|
||||
* synthesises a stable substitute rather than emitting a VTODO with no UID.
|
||||
*/
|
||||
val uid: String?,
|
||||
val title: String,
|
||||
val description: String?,
|
||||
val location: String?,
|
||||
val url: String?,
|
||||
val priority: Priority,
|
||||
val status: TaskStatus,
|
||||
val percentComplete: Int?,
|
||||
val start: Instant?,
|
||||
val due: Instant?,
|
||||
val isAllDay: Boolean,
|
||||
val completedAt: Instant?,
|
||||
val created: Instant?,
|
||||
val lastModified: Instant?,
|
||||
/** Raw `RRULE` value as stored, without the `RRULE:` name. Null when non-recurring. */
|
||||
val rrule: String?,
|
||||
/** Raw `RDATE` value as stored. Null when absent. */
|
||||
val rdate: String?,
|
||||
/** `tasks._id` of the parent, for `RELATED-TO;RELTYPE=PARENT`. */
|
||||
val parentId: Long?,
|
||||
)
|
||||
|
||||
/** A task list and everything in it, ready to become one `.ics` document. */
|
||||
data class ExportList(
|
||||
val listId: Long,
|
||||
val name: String,
|
||||
val accountName: String,
|
||||
val tasks: List<ExportTask>,
|
||||
)
|
||||
|
||||
/** A single file the export produced: [fileName] and its finished bytes. */
|
||||
data class ExportDocument(
|
||||
val fileName: String,
|
||||
val content: ByteArray,
|
||||
) {
|
||||
// ByteArray gets identity equals/hashCode, which makes this data class lie.
|
||||
override fun equals(other: Any?): Boolean =
|
||||
this === other ||
|
||||
(other is ExportDocument && fileName == other.fileName && content.contentEquals(other.content))
|
||||
|
||||
override fun hashCode(): Int = 31 * fileName.hashCode() + content.contentHashCode()
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package de.jeanlucmakiola.agendula.domain.export
|
||||
|
||||
import de.jeanlucmakiola.agendula.domain.Priority
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import de.jeanlucmakiola.agendula.domain.calendarDate
|
||||
import de.jeanlucmakiola.agendula.domain.toICal
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Writes a task list as an RFC 5545 `VCALENDAR` of `VTODO` components.
|
||||
*
|
||||
* Pure Kotlin and deliberately free of any Android type, so the format — the part
|
||||
* that decides whether an exported backup can actually be read again — is
|
||||
* unit-testable on the JVM. Serialising tasks is task-domain and stays here; the
|
||||
* SAF/file plumbing that carries the bytes out is not, and lives in the data
|
||||
* layer (and is the piece `docs/STORAGE-AND-SYNC.md` marks as a floret-kit
|
||||
* candidate).
|
||||
*
|
||||
* **Times are always written in UTC.** Emitting a local `TZID` would oblige us to
|
||||
* also emit a matching `VTIMEZONE` component with its full transition rules, and
|
||||
* a `TZID` referencing an absent definition is what actually breaks importers. UTC
|
||||
* is unambiguous and universally accepted, so the exported instant is exact even
|
||||
* though the original wall-clock zone is not carried. All-day values keep their
|
||||
* `VALUE=DATE` form and stay date-only, which is the only representation that
|
||||
* survives a timezone change intact.
|
||||
*/
|
||||
object ICalendarWriter {
|
||||
|
||||
private const val PRODUCT_ID = "-//Jean-Luc Makiola//Agendula//EN"
|
||||
|
||||
/** RFC 5545 caps a content line at 75 octets, excluding the CRLF. */
|
||||
private const val MAX_LINE_OCTETS = 75
|
||||
|
||||
private val DATE = DateTimeFormatter.ofPattern("yyyyMMdd")
|
||||
private val DATE_TIME_UTC = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'")
|
||||
|
||||
/** Serialises [list] to a complete `.ics` document. */
|
||||
fun write(list: ExportList): String = buildString {
|
||||
line("BEGIN:VCALENDAR")
|
||||
line("VERSION:2.0")
|
||||
line("PRODID:$PRODUCT_ID")
|
||||
line("CALSCALE:GREGORIAN")
|
||||
// Non-standard but near-universally understood, and the only way the list's
|
||||
// name survives into a calendar app. Importers that don't know it skip it.
|
||||
property("X-WR-CALNAME", list.name)
|
||||
|
||||
// Parents must be addressable by UID, and a subtask may appear before its
|
||||
// parent in the list, so resolve every id up front.
|
||||
val uidsById = list.tasks.associate { it.taskId to uidFor(it) }
|
||||
list.tasks.forEach { task -> writeTask(task, uidsById) }
|
||||
|
||||
line("END:VCALENDAR")
|
||||
}
|
||||
|
||||
/**
|
||||
* The UID to write for [task].
|
||||
*
|
||||
* Local tasks have none, because nothing assigns one: the provider never
|
||||
* generates a `_uid` itself, and our write path does not set it either, so in
|
||||
* Local mode every task arrives here with `uid == null`.
|
||||
*
|
||||
* Note this is a gap we leave open, not one the provider imposes.
|
||||
* `processors/tasks/Validating.java:92-96` restricts `_uid` to sync adapters
|
||||
* on *update* only; `insert` does not check it, so any caller may assign a UID
|
||||
* at creation. Doing that would be strictly better than synthesising here —
|
||||
* see `docs/SYNC.md`, where it is a phase-1 item, because a real UID minted at
|
||||
* creation is what lets a local task later be pushed to CalDAV without
|
||||
* duplicating.
|
||||
*
|
||||
* Until then: a VTODO without a UID is invalid and, worse, un-mergeable —
|
||||
* re-importing a backup would duplicate every task instead of matching it. So
|
||||
* we synthesise one from the row id, which is stable for as long as the row
|
||||
* is, and tag it with our own domain so a synthesised UID is recognisable as
|
||||
* such.
|
||||
*/
|
||||
fun uidFor(task: ExportTask): String =
|
||||
task.uid?.takeIf { it.isNotBlank() } ?: "agendula-${task.taskId}@jeanlucmakiola.de"
|
||||
|
||||
private fun StringBuilder.writeTask(task: ExportTask, uidsById: Map<Long, String>) {
|
||||
line("BEGIN:VTODO")
|
||||
property("UID", uidFor(task))
|
||||
// DTSTAMP is mandatory. It means "when this representation was written",
|
||||
// which for an export is now — not the task's own timestamps.
|
||||
property("DTSTAMP", formatUtc(Instant.fromEpochMilliseconds(System.currentTimeMillis())))
|
||||
property("SUMMARY", task.title)
|
||||
|
||||
task.description?.takeIf { it.isNotBlank() }?.let { property("DESCRIPTION", it) }
|
||||
task.location?.takeIf { it.isNotBlank() }?.let { property("LOCATION", it) }
|
||||
// URL is a URI, not TEXT: it must not be escaped like one.
|
||||
task.url?.takeIf { it.isNotBlank() }?.let { rawProperty("URL", it) }
|
||||
|
||||
task.start?.let { dateProperty("DTSTART", it, task.isAllDay) }
|
||||
task.due?.let { dateProperty("DUE", it, task.isAllDay) }
|
||||
task.created?.let { rawProperty("CREATED", formatUtc(it)) }
|
||||
task.lastModified?.let { rawProperty("LAST-MODIFIED", formatUtc(it)) }
|
||||
// COMPLETED is defined as UTC date-time even for an all-day task.
|
||||
task.completedAt?.let { rawProperty("COMPLETED", formatUtc(it)) }
|
||||
|
||||
rawProperty("STATUS", task.status.toICalName())
|
||||
if (task.priority != Priority.NONE) rawProperty("PRIORITY", task.priority.toICal().toString())
|
||||
task.percentComplete?.coerceIn(0, 100)?.let { rawProperty("PERCENT-COMPLETE", it.toString()) }
|
||||
|
||||
// Passed through as stored. The provider keeps these in iCalendar form
|
||||
// already, and re-deriving them would risk changing what the user's
|
||||
// recurrence actually means.
|
||||
task.rrule?.takeIf { it.isNotBlank() }?.let { rawProperty("RRULE", it) }
|
||||
task.rdate?.takeIf { it.isNotBlank() }?.let { rawProperty("RDATE", it) }
|
||||
|
||||
// Only emit the link when the parent is in this same document; a
|
||||
// RELATED-TO pointing outside the file would dangle on import.
|
||||
task.parentId?.let { uidsById[it] }?.let {
|
||||
property("RELATED-TO;RELTYPE=PARENT", it)
|
||||
}
|
||||
|
||||
line("END:VTODO")
|
||||
}
|
||||
|
||||
private fun StringBuilder.dateProperty(name: String, instant: Instant, allDay: Boolean) {
|
||||
if (allDay) {
|
||||
// Read in UTC, matching the storage convention (see AllDayTime): an
|
||||
// all-day value *is* UTC midnight of the intended calendar date.
|
||||
rawProperty("$name;VALUE=DATE", instant.calendarDate(allDay = true).format(DATE))
|
||||
} else {
|
||||
rawProperty(name, formatUtc(instant))
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatUtc(instant: Instant): String =
|
||||
java.time.Instant.ofEpochMilli(instant.toEpochMilliseconds())
|
||||
.atZone(ZoneOffset.UTC)
|
||||
.format(DATE_TIME_UTC)
|
||||
|
||||
/** A property whose value is TEXT, and so must be escaped. */
|
||||
private fun StringBuilder.property(name: String, value: String) =
|
||||
line("$name:${escapeText(value)}")
|
||||
|
||||
/** A property whose value is already in its final form (dates, numbers, URIs, rules). */
|
||||
private fun StringBuilder.rawProperty(name: String, value: String) = line("$name:$value")
|
||||
|
||||
private fun StringBuilder.line(content: String) {
|
||||
append(fold(content))
|
||||
append(CRLF)
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes a TEXT value per RFC 5545 §3.3.11. Backslash first, or it would
|
||||
* double the backslashes introduced by the later replacements.
|
||||
*/
|
||||
internal fun escapeText(value: String): String = value
|
||||
.replace("\\", "\\\\")
|
||||
.replace(";", "\\;")
|
||||
.replace(",", "\\,")
|
||||
.replace("\r\n", "\\n")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\n")
|
||||
|
||||
/**
|
||||
* Folds a content line to at most [MAX_LINE_OCTETS] octets, continuing with
|
||||
* CRLF + a single space.
|
||||
*
|
||||
* Counted in **octets, not characters** — the limit is defined that way, and an
|
||||
* emoji in a task title is four of them. Splits are kept on character
|
||||
* boundaries so folding can never cut a UTF-8 sequence in half and corrupt the
|
||||
* text; an importer unfolds by removing CRLF + leading whitespace, recovering
|
||||
* the original exactly.
|
||||
*/
|
||||
internal fun fold(content: String): String {
|
||||
if (content.utf8Size() <= MAX_LINE_OCTETS) return content
|
||||
|
||||
val out = StringBuilder()
|
||||
var octets = 0
|
||||
// First line takes the full budget; every continuation loses one octet to
|
||||
// the leading space.
|
||||
var budget = MAX_LINE_OCTETS
|
||||
var index = 0
|
||||
while (index < content.length) {
|
||||
val codePoint = content.codePointAt(index)
|
||||
val charCount = Character.charCount(codePoint)
|
||||
val size = String(Character.toChars(codePoint)).utf8Size()
|
||||
if (octets + size > budget) {
|
||||
out.append(CRLF).append(' ')
|
||||
octets = 0
|
||||
budget = MAX_LINE_OCTETS - 1
|
||||
}
|
||||
out.append(content, index, index + charCount)
|
||||
octets += size
|
||||
index += charCount
|
||||
}
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
private fun String.utf8Size(): Int = toByteArray(Charsets.UTF_8).size
|
||||
|
||||
private fun TaskStatus.toICalName(): String = when (this) {
|
||||
TaskStatus.NEEDS_ACTION -> "NEEDS-ACTION"
|
||||
TaskStatus.IN_PROCESS -> "IN-PROCESS"
|
||||
TaskStatus.COMPLETED -> "COMPLETED"
|
||||
TaskStatus.CANCELLED -> "CANCELLED"
|
||||
}
|
||||
|
||||
private const val CRLF = "\r\n"
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package de.jeanlucmakiola.agendula.domain.recurrence
|
||||
|
||||
import org.dmfs.rfc5545.DateTime
|
||||
import org.dmfs.rfc5545.recur.RecurrenceRule
|
||||
import org.dmfs.rfc5545.recurrenceset.RecurrenceList
|
||||
import org.dmfs.rfc5545.recurrenceset.RecurrenceRuleAdapter
|
||||
import org.dmfs.rfc5545.recurrenceset.RecurrenceSet
|
||||
import java.time.ZoneId
|
||||
import java.util.TimeZone
|
||||
import kotlin.time.Instant
|
||||
|
||||
private const val MILLIS_PER_SECOND = 1000L
|
||||
private const val MILLIS_PER_DAY = 24L * 60 * 60 * 1000
|
||||
|
||||
/** The rule set of one task series, as stored. All strings are raw iCalendar values. */
|
||||
data class RecurrenceSpec(
|
||||
val rrule: String?,
|
||||
val rdate: String?,
|
||||
val exdate: String?,
|
||||
/** The series anchor: DTSTART if present, else DUE. Never null for a recurring task. */
|
||||
val anchor: Instant,
|
||||
val isAllDay: Boolean,
|
||||
/** IANA zone id the anchor is expressed in; null means floating/local. */
|
||||
val timeZone: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* The window expansion is bounded to: [from] inclusive, [until] exclusive, and
|
||||
* never more than [maxOccurrences] results — so an unbounded `RRULE` terminates.
|
||||
*
|
||||
* [pivot] is where "now" sits inside the window, and it is what the occurrence
|
||||
* budget is spent around. Without it a series firing more often than about
|
||||
* once a day exhausts [maxOccurrences] inside the past alone — an eight-hourly
|
||||
* task would stop expanding months before today, so it would never appear in
|
||||
* Today or Upcoming at all. At most a quarter of the budget goes to occurrences
|
||||
* before [pivot], and the most recent of those are the ones kept.
|
||||
*/
|
||||
data class ExpansionWindow(
|
||||
val from: Instant,
|
||||
val until: Instant,
|
||||
val maxOccurrences: Int = 500,
|
||||
val pivot: Instant = from,
|
||||
)
|
||||
|
||||
/**
|
||||
* Expands a task series into its occurrences in memory, over `lib-recur`.
|
||||
*
|
||||
* There is no materialised instances table behind this: the repository already
|
||||
* filters and sorts in Kotlin, so occurrences are computed at read time and the
|
||||
* whole class of staleness bugs a cached table brings never exists.
|
||||
*/
|
||||
object RecurrenceExpander {
|
||||
|
||||
/**
|
||||
* Every occurrence of [spec] inside [window], as its `RECURRENCE-ID` anchor —
|
||||
* the instant identifying that occurrence within the series. Ascending,
|
||||
* deduplicated, `EXDATE` applied.
|
||||
*
|
||||
* The anchor itself is always part of the set (RFC 5545 §3.8.5.3: `DTSTART`
|
||||
* is the first instance), so a spec with no rule and no `RDATE` expands to
|
||||
* exactly its anchor. A malformed `RRULE`, `RDATE` or `EXDATE` is dropped
|
||||
* rather than thrown — a task whose stored rule cannot be parsed still has
|
||||
* to appear.
|
||||
*
|
||||
* [floatingZone] resolves a series with no [RecurrenceSpec.timeZone]; it is a
|
||||
* parameter rather than a `TimeZone.getDefault()` lookup so expansion is
|
||||
* deterministic under test.
|
||||
*/
|
||||
fun expand(
|
||||
spec: RecurrenceSpec,
|
||||
window: ExpansionWindow,
|
||||
floatingZone: ZoneId = ZoneId.systemDefault(),
|
||||
): List<Instant> {
|
||||
val zone = zoneOf(spec, floatingZone)
|
||||
val anchorMillis = anchorMillis(spec)
|
||||
|
||||
val set = RecurrenceSet()
|
||||
spec.rrule.orNull()?.let { raw -> ruleOf(raw, zone)?.let { set.addInstances(RecurrenceRuleAdapter(it)) } }
|
||||
spec.rdate.orNull()?.let { raw -> datesOf(raw, zone)?.let(set::addInstances) }
|
||||
spec.exdate.orNull()?.let { raw -> datesOf(raw, zone)?.let(set::addExceptions) }
|
||||
|
||||
val iterator = set.iterator(zone, anchorMillis, window.until.toEpochMilliseconds())
|
||||
iterator.fastForward(window.from.toEpochMilliseconds())
|
||||
|
||||
// Occurrences arrive ascending, so everything before the pivot lands first
|
||||
// and `past` is final by the time the first future one appears. Past is a
|
||||
// sliding window (the newest are the ones worth keeping); the rest of the
|
||||
// budget then goes to the future, undiminished when there is no past.
|
||||
val pastCap = window.maxOccurrences / 4
|
||||
val past = ArrayDeque<Instant>()
|
||||
val future = ArrayList<Instant>()
|
||||
var previous = Long.MIN_VALUE
|
||||
while (iterator.hasNext()) {
|
||||
val millis = iterator.next()
|
||||
if (millis == previous) continue
|
||||
previous = millis
|
||||
val at = Instant.fromEpochMilliseconds(millis)
|
||||
if (at < window.pivot) {
|
||||
if (past.size == pastCap) past.removeFirst()
|
||||
if (pastCap > 0) past.addLast(at)
|
||||
} else {
|
||||
future += at
|
||||
if (past.size + future.size >= window.maxOccurrences) break
|
||||
}
|
||||
}
|
||||
return past + future
|
||||
}
|
||||
|
||||
/**
|
||||
* Index of the current occurrence in an ascending [occurrences] list: the
|
||||
* first one at or after [now], or the last one when the whole series is in
|
||||
* the past. `-1` when there are no occurrences at all.
|
||||
*/
|
||||
fun currentOccurrenceIndex(occurrences: List<Instant>, now: Instant): Int {
|
||||
if (occurrences.isEmpty()) return -1
|
||||
val next = occurrences.indexOfFirst { it >= now }
|
||||
return if (next >= 0) next else occurrences.lastIndex
|
||||
}
|
||||
|
||||
/**
|
||||
* Each occurrence's distance from the current one, index-aligned with
|
||||
* [occurrences]. `0` is the current occurrence, negative counts back into the
|
||||
* past and positive counts forward — the convention `Task.distanceFromCurrent`
|
||||
* carries and the data sources pick the current occurrence by.
|
||||
*
|
||||
* Purely positional: unlike the dmfs provider, which drove the same number off
|
||||
* each instance's closed state, this knows only times. Completion-aware
|
||||
* refinement belongs where overrides carry their status.
|
||||
*/
|
||||
fun distancesFromCurrent(occurrences: List<Instant>, now: Instant): List<Int> {
|
||||
val current = currentOccurrenceIndex(occurrences, now)
|
||||
if (current < 0) return emptyList()
|
||||
return occurrences.indices.map { it - current }
|
||||
}
|
||||
|
||||
private fun zoneOf(spec: RecurrenceSpec, floatingZone: ZoneId): TimeZone {
|
||||
if (spec.isAllDay) return TimeZone.getTimeZone(ZoneId.of("UTC"))
|
||||
val stored = spec.timeZone?.let { runCatching { ZoneId.of(it) }.getOrNull() }
|
||||
return TimeZone.getTimeZone(stored ?: floatingZone)
|
||||
}
|
||||
|
||||
/**
|
||||
* All-day series are date-anchored: pin the anchor to UTC midnight, as it is
|
||||
* stored. A timed one is floored to the second, because RFC 5545 DATE-TIME
|
||||
* has no sub-second field — carrying millis in makes lib-recur emit the raw
|
||||
* anchor *and* its truncated self, doubling the first occurrence, and mints
|
||||
* `RECURRENCE-ID`s no other client could address.
|
||||
*/
|
||||
private fun anchorMillis(spec: RecurrenceSpec): Long {
|
||||
val millis = spec.anchor.toEpochMilliseconds()
|
||||
val unit = if (spec.isAllDay) MILLIS_PER_DAY else MILLIS_PER_SECOND
|
||||
return Math.floorDiv(millis, unit) * unit
|
||||
}
|
||||
|
||||
private fun ruleOf(value: String, zone: TimeZone): RecurrenceRule? = runCatching {
|
||||
RecurrenceRule(value).also { rule ->
|
||||
// lib-recur refuses to iterate a floating UNTIL against a zoned start,
|
||||
// and RFC 5545 §3.3.10 forbids that pairing — but stored rules carry it
|
||||
// anyway. Re-read the UNTIL's local fields in the series zone.
|
||||
val until = rule.until
|
||||
if (until != null && until.isFloating) {
|
||||
rule.until = DateTime(
|
||||
zone,
|
||||
until.year,
|
||||
until.month,
|
||||
until.dayOfMonth,
|
||||
until.hours,
|
||||
until.minutes,
|
||||
until.seconds,
|
||||
)
|
||||
}
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
private fun datesOf(value: String, zone: TimeZone): RecurrenceList? =
|
||||
runCatching { RecurrenceList(value, zone) }.getOrNull()
|
||||
|
||||
private fun String?.orNull(): String? = this?.trim()?.ifEmpty { null }
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -19,6 +20,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.ui.common.OnResume
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus
|
||||
import de.jeanlucmakiola.agendula.ui.navigation.AgendulaNavHost
|
||||
import de.jeanlucmakiola.agendula.ui.permission.PermissionViewModel
|
||||
@@ -39,11 +41,23 @@ fun RootScreen(
|
||||
ActivityResultContracts.RequestMultiplePermissions(),
|
||||
) { permissionViewModel.refresh() }
|
||||
|
||||
// Re-check on every resume, not just after the in-app request: the user may
|
||||
// have granted the permission (or installed a provider) in system Settings and
|
||||
// come back, and otherwise the gate would hold until the process restarts.
|
||||
OnResume { permissionViewModel.refresh() }
|
||||
|
||||
// Neither gate can show in OWN mode (that store is always READY), so the way
|
||||
// out of one is always our own store. Without it a user whose provider app
|
||||
// went away is held on this screen with Settings behind it.
|
||||
val fallback = stringResource(R.string.onboarding_use_own_store)
|
||||
|
||||
when (permission.status) {
|
||||
ProviderStatus.NO_PROVIDER -> Gate(
|
||||
modifier = modifier,
|
||||
title = stringResource(R.string.onboarding_no_provider_title),
|
||||
body = stringResource(R.string.onboarding_no_provider_body),
|
||||
secondaryAction = fallback,
|
||||
onSecondaryAction = permissionViewModel::useOwnStore,
|
||||
)
|
||||
ProviderStatus.NEEDS_PERMISSION -> Gate(
|
||||
modifier = modifier,
|
||||
@@ -51,6 +65,8 @@ fun RootScreen(
|
||||
body = stringResource(R.string.onboarding_permission_body),
|
||||
action = stringResource(R.string.onboarding_permission_button),
|
||||
onAction = { launcher.launch(permission.permissionsToRequest.toTypedArray()) },
|
||||
secondaryAction = fallback,
|
||||
onSecondaryAction = permissionViewModel::useOwnStore,
|
||||
)
|
||||
ProviderStatus.READY -> ReadyGate(modifier = modifier)
|
||||
}
|
||||
@@ -87,6 +103,8 @@ private fun Gate(
|
||||
modifier: Modifier = Modifier,
|
||||
action: String? = null,
|
||||
onAction: () -> Unit = {},
|
||||
secondaryAction: String? = null,
|
||||
onSecondaryAction: () -> Unit = {},
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize().padding(24.dp),
|
||||
@@ -96,5 +114,6 @@ private fun Gate(
|
||||
Text(title, style = MaterialTheme.typography.headlineSmall)
|
||||
Text(body, style = MaterialTheme.typography.bodyMedium)
|
||||
if (action != null) Button(onClick = onAction) { Text(action) }
|
||||
if (secondaryAction != null) TextButton(onClick = onSecondaryAction) { Text(secondaryAction) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.ui.common
|
||||
|
||||
import de.jeanlucmakiola.floret.time.formatDateTime
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Clear
|
||||
import androidx.compose.material.icons.rounded.Event
|
||||
import androidx.compose.material3.DatePicker
|
||||
import androidx.compose.material3.DatePickerDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TimePicker
|
||||
import androidx.compose.material3.rememberDatePickerState
|
||||
import androidx.compose.material3.rememberTimePickerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import kotlin.time.Instant
|
||||
|
||||
private val zone: ZoneId get() = ZoneId.systemDefault()
|
||||
|
||||
internal fun Instant.toLocalDate(): LocalDate =
|
||||
java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalDate()
|
||||
|
||||
internal fun Instant.toLocalTime(): LocalTime =
|
||||
java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalTime()
|
||||
|
||||
internal fun localToInstant(date: LocalDate, time: LocalTime): Instant =
|
||||
Instant.fromEpochMilliseconds(date.atTime(time).atZone(zone).toInstant().toEpochMilli())
|
||||
|
||||
/**
|
||||
* A labelled date(-time) field for the edit form: a tonal row showing the
|
||||
* current value (or nothing), tappable to pick a date and — unless [allDay] —
|
||||
* a time. A clear affordance appears once a value is set. Emits `null` when
|
||||
* cleared. Styled to match the app's rounded tonal family.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DateTimeField(
|
||||
label: String,
|
||||
value: Instant?,
|
||||
allDay: Boolean,
|
||||
onChange: (Instant?) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var showDatePicker by remember { mutableStateOf(false) }
|
||||
var showTimePicker by remember { mutableStateOf(false) }
|
||||
var pendingDate by remember { mutableStateOf<LocalDate?>(null) }
|
||||
|
||||
Surface(
|
||||
onClick = { showDatePicker = true },
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(Icons.Rounded.Event, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = value?.formatDateTime(allDay) ?: stringResource(R.string.edit_set),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
}
|
||||
if (value != null) {
|
||||
IconButton(onClick = { onChange(null) }) {
|
||||
Icon(Icons.Rounded.Clear, contentDescription = stringResource(R.string.edit_clear))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showDatePicker) {
|
||||
val initialMillis = (value ?: Instant.fromEpochMilliseconds(System.currentTimeMillis()))
|
||||
.toLocalDate().atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||
val dateState = rememberDatePickerState(initialSelectedDateMillis = initialMillis)
|
||||
DatePickerDialog(
|
||||
onDismissRequest = { showDatePicker = false },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showDatePicker = false
|
||||
val millis = dateState.selectedDateMillis ?: return@TextButton
|
||||
val date = java.time.Instant.ofEpochMilli(millis)
|
||||
.atZone(ZoneOffset.UTC).toLocalDate()
|
||||
if (allDay) {
|
||||
onChange(localToInstant(date, LocalTime.MIDNIGHT))
|
||||
} else {
|
||||
pendingDate = date
|
||||
showTimePicker = true
|
||||
}
|
||||
},
|
||||
) { Text(stringResource(android.R.string.ok)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDatePicker = false }) {
|
||||
Text(stringResource(android.R.string.cancel))
|
||||
}
|
||||
},
|
||||
) { DatePicker(state = dateState) }
|
||||
}
|
||||
|
||||
if (showTimePicker) {
|
||||
val base = value ?: Instant.fromEpochMilliseconds(System.currentTimeMillis())
|
||||
val timeState = rememberTimePickerState(
|
||||
initialHour = base.toLocalTime().hour,
|
||||
initialMinute = base.toLocalTime().minute,
|
||||
)
|
||||
Dialog(onDismissRequest = { showTimePicker = false }) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
TimePicker(state = timeState)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
TextButton(onClick = { showTimePicker = false }) {
|
||||
Text(stringResource(android.R.string.cancel))
|
||||
}
|
||||
TextButton(onClick = {
|
||||
showTimePicker = false
|
||||
val date = pendingDate ?: return@TextButton
|
||||
onChange(localToInstant(date, LocalTime.of(timeState.hour, timeState.minute)))
|
||||
}) { Text(stringResource(android.R.string.ok)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package de.jeanlucmakiola.agendula.ui.common
|
||||
|
||||
/**
|
||||
* The colours offered when creating or editing a task list.
|
||||
*
|
||||
* Raw ARGB, the way a CalDAV server sends one — every surface that draws a list
|
||||
* colour runs it through
|
||||
* [de.jeanlucmakiola.floret.components.pastelize] first, so these are hues
|
||||
* rather than final fills, chosen to stay distinguishable after that pass. A
|
||||
* list can still carry any colour a server gives it; this is only the set the
|
||||
* app hands out.
|
||||
*/
|
||||
val ListPalette: List<Int> = listOf(
|
||||
0xFF7A5C6B.toInt(), // mauve — Agendula's own seed
|
||||
0xFFD7484A.toInt(), // red
|
||||
0xFFE8743B.toInt(), // orange
|
||||
0xFFE0A32E.toInt(), // amber
|
||||
0xFF7CA83E.toInt(), // olive
|
||||
0xFF35A06A.toInt(), // green
|
||||
0xFF19938C.toInt(), // teal
|
||||
0xFF2A9BC4.toInt(), // cyan
|
||||
0xFF3C74C8.toInt(), // blue
|
||||
0xFF6A5CC0.toInt(), // indigo
|
||||
0xFF9455B8.toInt(), // purple
|
||||
0xFFC94F8E.toInt(), // pink
|
||||
)
|
||||
|
||||
/** What a new list gets before the user picks anything. */
|
||||
val DefaultListColor: Int = ListPalette.first()
|
||||
@@ -0,0 +1,29 @@
|
||||
package de.jeanlucmakiola.agendula.ui.common
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
|
||||
/**
|
||||
* Runs [block] on every `ON_RESUME`.
|
||||
*
|
||||
* For state the app cannot observe because it is granted, revoked or installed
|
||||
* outside it — a runtime permission, an exact-alarm allowance, a provider app —
|
||||
* which otherwise stays stale until the process restarts.
|
||||
*/
|
||||
@Composable
|
||||
fun OnResume(block: () -> Unit) {
|
||||
val current by rememberUpdatedState(block)
|
||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||
DisposableEffect(lifecycle) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) current()
|
||||
}
|
||||
lifecycle.addObserver(observer)
|
||||
onDispose { lifecycle.removeObserver(observer) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package de.jeanlucmakiola.agendula.ui.common
|
||||
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Zone helpers shared by the date/time pickers. All-day conversions live in
|
||||
* [de.jeanlucmakiola.agendula.domain.AllDayTime] — these cover the timed case,
|
||||
* where the device zone is the right frame of reference.
|
||||
*/
|
||||
|
||||
private val zone: ZoneId get() = ZoneId.systemDefault()
|
||||
|
||||
internal fun Instant.toLocalTime(): LocalTime =
|
||||
java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalTime()
|
||||
|
||||
internal fun localToInstant(date: LocalDate, time: LocalTime): Instant =
|
||||
Instant.fromEpochMilliseconds(date.atTime(time).atZone(zone).toInstant().toEpochMilli())
|
||||
@@ -54,7 +54,9 @@ fun ReminderLeadPicker(
|
||||
onDismiss: () -> Unit,
|
||||
presets: List<Int> = REMINDER_PRESETS,
|
||||
) {
|
||||
val selectedMinutes = (selected as? ReminderOverride.Minutes)?.minutes
|
||||
// Agendula is single-reminder: an override carries a one-element list, so
|
||||
// take the single value for this single-select picker.
|
||||
val selectedMinutes = (selected as? ReminderOverride.Minutes)?.minutes?.firstOrNull()
|
||||
val customSelected = selectedMinutes != null && selectedMinutes !in presets
|
||||
val seed = decomposeReminderMinutes(selectedMinutes?.takeIf { customSelected })
|
||||
|
||||
@@ -65,7 +67,7 @@ fun ReminderLeadPicker(
|
||||
val options = buildList {
|
||||
if (allowInherit) add(ReminderOverride.Inherit)
|
||||
if (allowNone) add(ReminderOverride.None)
|
||||
presets.forEach { add(ReminderOverride.Minutes(it)) }
|
||||
presets.forEach { add(ReminderOverride.Minutes(listOf(it))) }
|
||||
}
|
||||
val rowCount = options.size + 1 // + the custom row
|
||||
|
||||
@@ -105,7 +107,7 @@ fun ReminderLeadPicker(
|
||||
unit = unit,
|
||||
onUnitChange = { unit = it },
|
||||
onConfirm = { minutes ->
|
||||
onSelect(ReminderOverride.Minutes(minutes))
|
||||
onSelect(ReminderOverride.Minutes(listOf(minutes)))
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
@@ -168,5 +170,5 @@ private fun CustomReminderEditor(
|
||||
private fun reminderOverrideLabel(override: ReminderOverride): String = when (override) {
|
||||
ReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
|
||||
ReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||
is ReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes)
|
||||
is ReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes.first())
|
||||
}
|
||||
|
||||
@@ -98,4 +98,7 @@ object ActionShapes {
|
||||
|
||||
/** Search — a 6-sided cookie, the same family as [Settings] but distinct. */
|
||||
val Search: RoundedPolygon get() = MaterialShapes.Cookie6Sided
|
||||
|
||||
/** New list — a sunny burst beside the Lists header. */
|
||||
val AddList: RoundedPolygon get() = MaterialShapes.Sunny
|
||||
}
|
||||
|
||||
@@ -501,13 +501,17 @@ private fun taskWhenLines(task: Task): Pair<String, String?>? {
|
||||
val due = task.due
|
||||
return when {
|
||||
start != null && due != null -> {
|
||||
val sameDay = start.formatDate() == due.formatDate()
|
||||
val primary = if (sameDay) due.formatDate() else "${start.formatDate()} – ${due.formatDate()}"
|
||||
val secondary = if (task.isAllDay) null else "${start.formatTime()} – ${due.formatTime()}"
|
||||
val allDay = task.isAllDay
|
||||
val sameDay = start.formatDate(allDay) == due.formatDate(allDay)
|
||||
val primary =
|
||||
if (sameDay) due.formatDate(allDay)
|
||||
else "${start.formatDate(allDay)} – ${due.formatDate(allDay)}"
|
||||
val secondary = if (allDay) null else "${start.formatTime()} – ${due.formatTime()}"
|
||||
primary to secondary
|
||||
}
|
||||
due != null -> due.formatDate() to if (task.isAllDay) null else due.formatTime()
|
||||
start != null -> start.formatDate() to if (task.isAllDay) null else start.formatTime()
|
||||
due != null -> due.formatDate(task.isAllDay) to if (task.isAllDay) null else due.formatTime()
|
||||
start != null ->
|
||||
start.formatDate(task.isAllDay) to if (task.isAllDay) null else start.formatTime()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskDetail
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
@@ -11,7 +12,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -42,14 +42,14 @@ class TaskDetailViewModel @Inject constructor(
|
||||
if (detail == null) TaskDetailUiState.NotFound else TaskDetailUiState.Content(detail)
|
||||
}
|
||||
.onStart { emit(TaskDetailUiState.Loading) }
|
||||
.catch { emit(TaskDetailUiState.NotFound) }
|
||||
.recoveringFromProviderFailure { TaskDetailUiState.NotFound }
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskDetailUiState.Loading)
|
||||
|
||||
fun bind(id: Long) { taskId.value = id }
|
||||
|
||||
fun toggleComplete(task: Task) = viewModelScope.launch {
|
||||
runCatching { repository.setCompleted(task.taskId, !task.isCompleted) }
|
||||
runCatching { repository.setCompleted(task.taskId, task.occurrenceStart, !task.isCompleted) }
|
||||
}
|
||||
|
||||
fun delete(task: Task) = viewModelScope.launch {
|
||||
|
||||
@@ -99,7 +99,8 @@ import de.jeanlucmakiola.floret.time.formatTime
|
||||
import de.jeanlucmakiola.agendula.ui.common.localToInstant
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.agendula.ui.common.toLocalDate
|
||||
import de.jeanlucmakiola.agendula.domain.allDayInstantOf
|
||||
import de.jeanlucmakiola.agendula.domain.calendarDate
|
||||
import de.jeanlucmakiola.agendula.ui.common.toLocalTime
|
||||
import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel
|
||||
import java.time.LocalTime
|
||||
@@ -178,7 +179,7 @@ private fun EditContent(
|
||||
val accent = selectedList?.let { pastelize(it.color, dark) } ?: MaterialTheme.colorScheme.primary
|
||||
val gap = 12.dp
|
||||
|
||||
var pickerTarget by remember { mutableStateOf<PickerTarget?>(null) }
|
||||
var pickerTarget by rememberSaveable { mutableStateOf<PickerTarget?>(null) }
|
||||
var showListPicker by rememberSaveable { mutableStateOf(false) }
|
||||
var showParentPicker by rememberSaveable { mutableStateOf(false) }
|
||||
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
|
||||
@@ -653,7 +654,7 @@ private fun ScheduleRow(
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = value.formatDate(),
|
||||
text = value.formatDate(allDay),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = valueColor,
|
||||
modifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp),
|
||||
@@ -689,12 +690,15 @@ private fun DateTimePickerFlow(
|
||||
onResult: (Instant) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var pendingDate by remember { mutableStateOf<java.time.LocalDate?>(null) }
|
||||
var showTime by remember { mutableStateOf(false) }
|
||||
var pendingDate by rememberSaveable { mutableStateOf<java.time.LocalDate?>(null) }
|
||||
var showTime by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
if (!showTime) {
|
||||
// M3's DatePicker speaks UTC millis. An all-day value is already UTC-based,
|
||||
// a timed one is read in the device zone — calendarDate picks the right frame
|
||||
// so the dialog opens on the day the rest of the UI shows.
|
||||
val initialMillis = (initial ?: nowInstant())
|
||||
.toLocalDate().atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||
.calendarDate(allDay).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||
val dateState = rememberDatePickerState(initialSelectedDateMillis = initialMillis)
|
||||
DatePickerDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
@@ -703,7 +707,7 @@ private fun DateTimePickerFlow(
|
||||
val millis = dateState.selectedDateMillis ?: run { onDismiss(); return@TextButton }
|
||||
val date = java.time.Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||
if (allDay) {
|
||||
onResult(localToInstant(date, LocalTime.MIDNIGHT))
|
||||
onResult(allDayInstantOf(date))
|
||||
} else {
|
||||
pendingDate = date
|
||||
showTime = true
|
||||
@@ -865,7 +869,7 @@ private fun ParentPickerSheet(
|
||||
GroupedRow(
|
||||
title = task.title.ifBlank { stringResource(R.string.task_untitled) },
|
||||
position = positionOf(index, section.tasks.size),
|
||||
summary = task.due?.formatDate(),
|
||||
summary = task.due?.formatDate(task.isAllDay),
|
||||
selected = task.taskId == selectedId,
|
||||
minHeight = 56.dp,
|
||||
onClick = { choose(task.taskId) },
|
||||
|
||||
@@ -15,6 +15,7 @@ import de.jeanlucmakiola.agendula.domain.TaskFormError
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.agendula.domain.populatedFields
|
||||
import de.jeanlucmakiola.agendula.domain.rebasedForAllDay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -70,6 +71,15 @@ class TaskEditViewModel @Inject constructor(
|
||||
|
||||
private var editingTaskId: Long? = null
|
||||
|
||||
/**
|
||||
* Whether the form has already been populated. The host `LaunchedEffect`
|
||||
* re-fires whenever the composition restarts — an Activity recreation
|
||||
* (rotation, theme/font/display-size change, split-screen, unfolding) — while
|
||||
* this ViewModel survives on the nav back stack. Without this guard the
|
||||
* rebind would overwrite in-progress edits with the untouched provider row.
|
||||
*/
|
||||
private var bound = false
|
||||
|
||||
/** `last_modified` captured when the form loaded — the conflict-check baseline. */
|
||||
private var baselineLastModified: Instant? = null
|
||||
|
||||
@@ -78,6 +88,8 @@ class TaskEditViewModel @Inject constructor(
|
||||
|
||||
/** Start a fresh task, optionally pre-selecting a list / parent. */
|
||||
fun bindNew(presetListId: Long? = null, parentId: Long? = null) {
|
||||
if (bound) return
|
||||
bound = true
|
||||
editingTaskId = null
|
||||
baselineLastModified = null
|
||||
viewModelScope.launch {
|
||||
@@ -103,6 +115,8 @@ class TaskEditViewModel @Inject constructor(
|
||||
|
||||
/** Load an existing task for editing. */
|
||||
fun bindEdit(taskId: Long) {
|
||||
if (bound && editingTaskId == taskId) return
|
||||
bound = true
|
||||
editingTaskId = taskId
|
||||
viewModelScope.launch {
|
||||
defaultFields = settingsPrefs.settings.first().defaultEditFields
|
||||
@@ -126,6 +140,7 @@ class TaskEditViewModel @Inject constructor(
|
||||
priority = task.priority,
|
||||
parentId = task.parentId,
|
||||
percentComplete = task.percentComplete,
|
||||
reminderMinutesBeforeDue = repository.reminderFor(taskId),
|
||||
lists = lists,
|
||||
parentCandidates = loadParents(task.listId, selfId = taskId),
|
||||
),
|
||||
@@ -178,7 +193,19 @@ class TaskEditViewModel @Inject constructor(
|
||||
|
||||
fun onStartChange(value: Instant?) = update { it.copy(start = value) }
|
||||
fun onDueChange(value: Instant?) = update { it.copy(due = value) }
|
||||
fun onAllDayChange(value: Boolean) = update { it.copy(isAllDay = value) }
|
||||
/**
|
||||
* All-day and timed values use different conventions (UTC midnight vs. a real
|
||||
* instant in the device zone), so the switch has to move the timestamps too —
|
||||
* flipping the flag alone makes an all-day task read back as "02:00", which
|
||||
* looks to the user like the time reset itself.
|
||||
*/
|
||||
fun onAllDayChange(value: Boolean) = update {
|
||||
it.copy(
|
||||
isAllDay = value,
|
||||
start = it.start?.rebasedForAllDay(value),
|
||||
due = it.due?.rebasedForAllDay(value),
|
||||
)
|
||||
}
|
||||
fun onPriorityChange(value: Priority) = update { it.copy(priority = value) }
|
||||
fun onPercentChange(value: Int?) = update { it.copy(percentComplete = value?.coerceIn(0, 100)) }
|
||||
fun onParentChange(parentId: Long?) = update { it.copy(parentId = parentId) }
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
package de.jeanlucmakiola.agendula.ui.export
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Circle
|
||||
import androidx.compose.material.icons.rounded.Folder
|
||||
import androidx.compose.material.icons.rounded.FolderZip
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.data.export.ExportFailure
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
|
||||
private const val ZIP_MIME = "application/zip"
|
||||
private const val ZIP_NAME = "agendula-tasks.zip"
|
||||
|
||||
/**
|
||||
* Export the task lists as iCalendar. Which lists go out is a per-list tick; the
|
||||
* destination is a folder or a single zip, both picked through SAF so the app
|
||||
* needs no storage permission.
|
||||
*/
|
||||
@Composable
|
||||
fun ExportScreen(
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: ExportViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val dark = isSystemInDarkTheme()
|
||||
|
||||
val folderLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocumentTree(),
|
||||
) { uri -> uri?.let(viewModel::exportToFolder) }
|
||||
val zipLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument(ZIP_MIME),
|
||||
) { uri -> uri?.let(viewModel::exportToZip) }
|
||||
|
||||
val canExport = !state.running && state.selectedCount > 0
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_export),
|
||||
onBack = onBack,
|
||||
modifier = modifier,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.export_hint),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
if (state.lists.isEmpty()) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.export_no_lists),
|
||||
position = Position.Alone,
|
||||
dimmed = true,
|
||||
)
|
||||
} else {
|
||||
state.lists.forEachIndexed { index, list ->
|
||||
val selected = state.isSelected(list.id)
|
||||
GroupedRow(
|
||||
title = list.name,
|
||||
// The account only says something when it isn't the device itself.
|
||||
summary = list.accountName.takeIf { !list.isLocal },
|
||||
position = positionOf(index, state.lists.size),
|
||||
leading = {
|
||||
Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark))
|
||||
},
|
||||
trailing = {
|
||||
Checkbox(checked = selected, onCheckedChange = { viewModel.toggle(list.id) })
|
||||
},
|
||||
onClick = { viewModel.toggle(list.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = { folderLauncher.launch(null) },
|
||||
enabled = canExport,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(Icons.Rounded.Folder, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(stringResource(R.string.export_to_folder))
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { zipLauncher.launch(ZIP_NAME) },
|
||||
enabled = canExport,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(Icons.Rounded.FolderZip, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(stringResource(R.string.export_to_zip))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
ExportStatus(state = state)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
/** The running spinner, then whatever the last export ended as — it stays put. */
|
||||
@Composable
|
||||
private fun ExportStatus(state: ExportUiState) {
|
||||
val outcome = state.outcome
|
||||
when {
|
||||
state.running -> Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
CircularProgressIndicator(Modifier.size(18.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.export_running),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
outcome is ExportOutcome.Success -> StatusText(
|
||||
text = pluralStringResource(R.plurals.export_done, outcome.fileCount, outcome.fileCount),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
outcome is ExportOutcome.Failure -> StatusText(
|
||||
text = stringResource(failureMessage(outcome.reason)),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun failureMessage(reason: ExportFailure): Int = when (reason) {
|
||||
ExportFailure.FOLDER_UNAVAILABLE -> R.string.export_failed_folder
|
||||
ExportFailure.FOLDER_NOT_WRITABLE -> R.string.export_failed_read_only
|
||||
ExportFailure.CANNOT_CREATE_FILE -> R.string.export_failed_create
|
||||
ExportFailure.LOST_ACCESS -> R.string.export_failed_access
|
||||
ExportFailure.WRITE_FAILED -> R.string.export_failed
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusText(text: String, color: Color) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = color,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package de.jeanlucmakiola.agendula.ui.export
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.agendula.data.export.ExportFailedException
|
||||
import de.jeanlucmakiola.agendula.data.export.ExportFailure
|
||||
import de.jeanlucmakiola.agendula.data.export.ExportResult
|
||||
import de.jeanlucmakiola.agendula.data.export.ExportWriter
|
||||
import de.jeanlucmakiola.agendula.data.export.TaskExporter
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.agendula.domain.export.ExportDocument
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/** How the last export ended, kept on screen rather than flashed past. */
|
||||
sealed interface ExportOutcome {
|
||||
data class Success(val fileCount: Int) : ExportOutcome
|
||||
data class Failure(val reason: ExportFailure) : ExportOutcome
|
||||
}
|
||||
|
||||
data class ExportUiState(
|
||||
val lists: List<TaskList> = emptyList(),
|
||||
/** Lists the user has ticked *off*; everything else is included. */
|
||||
val excluded: Set<Long> = emptySet(),
|
||||
val running: Boolean = false,
|
||||
val outcome: ExportOutcome? = null,
|
||||
) {
|
||||
fun isSelected(listId: Long): Boolean = listId !in excluded
|
||||
|
||||
val selectedCount: Int get() = lists.count { isSelected(it.id) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the export screen. Holds the selection as an exclusion set so a list
|
||||
* that appears while the screen is open is exported too — the natural reading of
|
||||
* "everything, minus what I unticked".
|
||||
*/
|
||||
@HiltViewModel
|
||||
class ExportViewModel @Inject constructor(
|
||||
repository: TasksRepository,
|
||||
resolver: ProviderResolver,
|
||||
private val exporter: TaskExporter,
|
||||
private val writer: ExportWriter,
|
||||
) : ViewModel() {
|
||||
|
||||
private val excluded = MutableStateFlow(emptySet<Long>())
|
||||
private val running = MutableStateFlow(false)
|
||||
private val outcome = MutableStateFlow<ExportOutcome?>(null)
|
||||
|
||||
private var exportJob: Job? = null
|
||||
|
||||
// List ids are per-store, and Settings can switch stores with this ViewModel
|
||||
// still alive — so the selection, the receipt and a write already addressing
|
||||
// the old store's lists all go with it.
|
||||
private val modeHandle = resolver.onModeChanged {
|
||||
exportJob?.cancel()
|
||||
excluded.value = emptySet()
|
||||
outcome.value = null
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
modeHandle.close()
|
||||
}
|
||||
|
||||
val state: StateFlow<ExportUiState> =
|
||||
combine(
|
||||
repository.taskLists().recoveringFromProviderFailure { emptyList() },
|
||||
excluded,
|
||||
running,
|
||||
outcome,
|
||||
) { lists, excluded, running, outcome ->
|
||||
ExportUiState(lists, excluded, running, outcome)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ExportUiState())
|
||||
|
||||
fun toggle(listId: Long) = excluded.update { current ->
|
||||
if (listId in current) current - listId else current + listId
|
||||
}
|
||||
|
||||
/** Writes one `.ics` per list into a folder the user picked through SAF. */
|
||||
fun exportToFolder(tree: Uri) = export { documents -> writer.writeToTree(tree, documents) }
|
||||
|
||||
/** Writes every list into a single zip the user named through SAF. */
|
||||
fun exportToZip(target: Uri) = export { documents -> writer.writeZip(target, documents) }
|
||||
|
||||
private fun export(write: suspend (List<ExportDocument>) -> ExportResult) {
|
||||
if (running.value) return
|
||||
running.value = true
|
||||
outcome.value = null
|
||||
exportJob = viewModelScope.launch {
|
||||
// Null means "every list" to the exporter, and is what an untouched
|
||||
// screen should send: the flow may not have emitted a list yet.
|
||||
val selection = excluded.value.takeIf { it.isNotEmpty() }
|
||||
?.let { skipped -> state.value.lists.map { it.id }.toSet() - skipped }
|
||||
try {
|
||||
val result = write(exporter.export(selection))
|
||||
outcome.value = ExportOutcome.Success(result.fileCount)
|
||||
} catch (cancelled: CancellationException) {
|
||||
// Leaving the screen mid-write is not a failed export.
|
||||
throw cancelled
|
||||
} catch (error: Exception) {
|
||||
outcome.value = ExportOutcome.Failure(
|
||||
(error as? ExportFailedException)?.failure ?: ExportFailure.WRITE_FAILED,
|
||||
)
|
||||
} finally {
|
||||
running.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
package de.jeanlucmakiola.agendula.ui.lists
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Check
|
||||
import androidx.compose.material.icons.rounded.DeleteOutline
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.agendula.ui.common.DefaultListColor
|
||||
import de.jeanlucmakiola.agendula.ui.common.ListColorChip
|
||||
import de.jeanlucmakiola.agendula.ui.common.ListPalette
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedSurface
|
||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
|
||||
private const val SWATCHES_PER_ROW = 6
|
||||
|
||||
/**
|
||||
* Create or edit a task list: a name field over the palette of list colours, on
|
||||
* the family's full-screen sheet with the commit in its title bar.
|
||||
*
|
||||
* [initial] null is the create case. [onDelete] is null for a list the app must
|
||||
* not remove — an account's collection belongs to its server — which is also why
|
||||
* the destructive row only appears when it is non-null.
|
||||
*/
|
||||
@Composable
|
||||
fun ListEditorSheet(
|
||||
initial: TaskList?,
|
||||
onSave: (name: String, color: Int) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
onDelete: (() -> Unit)? = null,
|
||||
) {
|
||||
var name by rememberSaveable(initial?.id) { mutableStateOf(initial?.name.orEmpty()) }
|
||||
var color by rememberSaveable(initial?.id) { mutableIntStateOf(initial?.color ?: DefaultListColor) }
|
||||
var confirmDelete by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val valid = name.isNotBlank()
|
||||
val commit = {
|
||||
if (valid) {
|
||||
onSave(name.trim(), color)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
FullScreenPicker(
|
||||
title = stringResource(if (initial == null) R.string.list_new_title else R.string.list_edit_title),
|
||||
onDismiss = onDismiss,
|
||||
actions = {
|
||||
Button(
|
||||
onClick = commit,
|
||||
enabled = valid,
|
||||
modifier = Modifier.padding(end = 12.dp),
|
||||
) { Text(stringResource(R.string.save)) }
|
||||
},
|
||||
) {
|
||||
NameField(
|
||||
name = name,
|
||||
color = color,
|
||||
// A new list opens with the keyboard up: naming it is the whole task.
|
||||
autoFocus = initial == null,
|
||||
onNameChange = { name = it },
|
||||
onImeAction = commit,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
SectionLabel(stringResource(R.string.list_color))
|
||||
ColorGrid(selected = color, onSelect = { color = it })
|
||||
|
||||
if (onDelete != null) {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
DeleteRow(onClick = { confirmDelete = true })
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
|
||||
if (confirmDelete && onDelete != null) {
|
||||
DeleteListDialog(
|
||||
listName = initial?.name.orEmpty(),
|
||||
onConfirm = {
|
||||
confirmDelete = false
|
||||
onDelete()
|
||||
onDismiss()
|
||||
},
|
||||
onDismiss = { confirmDelete = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The name, with the chosen colour beside it so the two read as one thing. */
|
||||
@Composable
|
||||
private fun NameField(
|
||||
name: String,
|
||||
color: Int,
|
||||
autoFocus: Boolean,
|
||||
onNameChange: (String) -> Unit,
|
||||
onImeAction: () -> Unit,
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(autoFocus) { if (autoFocus) focusRequester.requestFocus() }
|
||||
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
ListColorChip(color)
|
||||
InlineTextField(
|
||||
value = name,
|
||||
onValueChange = onNameChange,
|
||||
placeholder = stringResource(R.string.list_name_hint),
|
||||
imeAction = ImeAction.Done,
|
||||
onImeAction = onImeAction,
|
||||
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The palette as two rows of round swatches; the chosen one carries a check. */
|
||||
@Composable
|
||||
private fun ColorGrid(selected: Int, onSelect: (Int) -> Unit) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
ListPalette.chunked(SWATCHES_PER_ROW).forEach { row ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
row.forEach { swatch ->
|
||||
Swatch(
|
||||
color = swatch,
|
||||
dark = dark,
|
||||
selected = swatch == selected,
|
||||
onClick = { onSelect(swatch) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
// Keeps a short final row's swatches at the same size as a full
|
||||
// one's rather than stretching them across the width.
|
||||
repeat(SWATCHES_PER_ROW - row.size) { Spacer(Modifier.weight(1f)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Swatch(
|
||||
color: Int,
|
||||
dark: Boolean,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val fill = pastelize(color, dark)
|
||||
val label = stringResource(colorLabel(color))
|
||||
Box(
|
||||
modifier = modifier
|
||||
.aspectRatio(1f)
|
||||
.clip(CircleShape)
|
||||
.background(fill)
|
||||
// selectable, not clickable: the swatch carries its chosen state in
|
||||
// semantics, so the check below is decoration rather than the only cue.
|
||||
.selectable(selected = selected, role = Role.RadioButton, onClick = onClick)
|
||||
.semantics { contentDescription = label },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (selected) {
|
||||
Icon(
|
||||
Icons.Rounded.Check,
|
||||
contentDescription = null,
|
||||
tint = if (fill.luminance() > 0.5f) Color.Black else Color.White,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeleteRow(onClick: () -> Unit) {
|
||||
GroupedSurface(
|
||||
position = Position.Alone,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
onClick = onClick,
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 64.dp).padding(horizontal = 20.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Rounded.DeleteOutline,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.list_delete),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeleteListDialog(listName: String, onConfirm: () -> Unit, onDismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.list_delete_confirm_title)) },
|
||||
text = { Text(stringResource(R.string.list_delete_confirm_message, listName)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm) {
|
||||
Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionLabel(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/** Names the palette entries for screen readers; anything else is just "colour". */
|
||||
private fun colorLabel(color: Int): Int = when (ListPalette.indexOf(color)) {
|
||||
0 -> R.string.list_color_mauve
|
||||
1 -> R.string.list_color_red
|
||||
2 -> R.string.list_color_orange
|
||||
3 -> R.string.list_color_amber
|
||||
4 -> R.string.list_color_olive
|
||||
5 -> R.string.list_color_green
|
||||
6 -> R.string.list_color_teal
|
||||
7 -> R.string.list_color_cyan
|
||||
8 -> R.string.list_color_blue
|
||||
9 -> R.string.list_color_indigo
|
||||
10 -> R.string.list_color_purple
|
||||
11 -> R.string.list_color_pink
|
||||
else -> R.string.list_color
|
||||
}
|
||||
@@ -46,6 +46,7 @@ import androidx.compose.material.icons.rounded.Upcoming
|
||||
import androidx.compose.material3.CircularWavyProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -80,6 +81,10 @@ import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||
import de.jeanlucmakiola.agendula.ui.common.ActionShapes
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.SnackChip
|
||||
import de.jeanlucmakiola.floret.components.SnackChipHeight
|
||||
import de.jeanlucmakiola.floret.components.SnackChipMargin
|
||||
import kotlinx.coroutines.delay
|
||||
import de.jeanlucmakiola.agendula.ui.common.ListColorChip
|
||||
import de.jeanlucmakiola.agendula.ui.common.ShapedActionButton
|
||||
import de.jeanlucmakiola.agendula.ui.common.priorityAccent
|
||||
@@ -108,10 +113,22 @@ fun ListsScreen(
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
var query by rememberSaveable { mutableStateOf("") }
|
||||
var searchActive by rememberSaveable { mutableStateOf(false) }
|
||||
var newList by rememberSaveable { mutableStateOf(false) }
|
||||
val closeSearch = {
|
||||
query = ""
|
||||
searchActive = false
|
||||
}
|
||||
// With no lists there is nowhere to put a task, so the primary action becomes
|
||||
// making one — otherwise a fresh install's FAB opens a form that cannot save.
|
||||
val noLists = (state as? ListsUiState.Content)?.groups?.isEmpty() == true
|
||||
// The sheet closes on save, so a refused write has to report itself here.
|
||||
val writeFailure by viewModel.writeFailure.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(writeFailure) {
|
||||
if (writeFailure != null) {
|
||||
delay(4_000)
|
||||
viewModel.clearWriteFailure()
|
||||
}
|
||||
}
|
||||
// System back closes search before leaving the screen.
|
||||
BackHandler(enabled = searchActive, onBack = closeSearch)
|
||||
|
||||
@@ -130,9 +147,11 @@ fun ListsScreen(
|
||||
// The FAB would otherwise float over the search results.
|
||||
if (!searchActive) {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = onNewTask,
|
||||
onClick = if (noLists) ({ newList = true }) else onNewTask,
|
||||
icon = { Icon(Icons.Rounded.Add, contentDescription = null) },
|
||||
text = { Text(stringResource(R.string.new_task)) },
|
||||
text = {
|
||||
Text(stringResource(if (noLists) R.string.list_add else R.string.new_task))
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -147,6 +166,7 @@ fun ListsScreen(
|
||||
state = s,
|
||||
onOpenFilter = onOpenFilter,
|
||||
onOpenTask = onOpenTask,
|
||||
onNewList = { newList = true },
|
||||
topPadding = 0.dp,
|
||||
bottomPadding = inner.calculateBottomPadding() + 96.dp,
|
||||
)
|
||||
@@ -166,8 +186,33 @@ fun ListsScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Anchored beside the FAB, at its height — the same receipt placement
|
||||
// the task list uses.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(
|
||||
start = SnackChipMargin,
|
||||
bottom = inner.calculateBottomPadding() + SnackChipMargin,
|
||||
)
|
||||
.height(SnackChipHeight),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
SnackChip(
|
||||
visible = writeFailure != null,
|
||||
message = stringResource(R.string.list_save_failed),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newList) {
|
||||
ListEditorSheet(
|
||||
initial = null,
|
||||
onSave = viewModel::createList,
|
||||
onDismiss = { newList = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -175,6 +220,7 @@ private fun ListsContent(
|
||||
state: ListsUiState.Content,
|
||||
onOpenFilter: (TaskFilter) -> Unit,
|
||||
onOpenTask: (Long) -> Unit,
|
||||
onNewList: () -> Unit,
|
||||
topPadding: androidx.compose.ui.unit.Dp,
|
||||
bottomPadding: androidx.compose.ui.unit.Dp,
|
||||
) {
|
||||
@@ -215,9 +261,21 @@ private fun ListsContent(
|
||||
}
|
||||
|
||||
if (state.groups.isEmpty()) {
|
||||
item { CenteredMessage(stringResource(R.string.lists_empty), PaddingValues(top = 24.dp)) }
|
||||
item { EmptyLists(onNewList = onNewList) }
|
||||
} else {
|
||||
item { SectionHeader(stringResource(R.string.lists_header)) }
|
||||
item {
|
||||
SectionHeader(
|
||||
text = stringResource(R.string.lists_header),
|
||||
action = {
|
||||
ShapedActionButton(
|
||||
shape = ActionShapes.AddList,
|
||||
icon = Icons.Rounded.Add,
|
||||
contentDescription = stringResource(R.string.list_add),
|
||||
onClick = onNewList,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
state.groups.forEach { group ->
|
||||
item(key = "acct-${group.accountName}") { AccountHeader(group.accountName) }
|
||||
itemsIndexed(group.lists, key = { _, o -> o.list.id }) { index, overview ->
|
||||
@@ -243,6 +301,28 @@ private fun ListsContent(
|
||||
}
|
||||
}
|
||||
|
||||
/** No lists at all — a fresh install, where nothing else on this screen works yet. */
|
||||
@Composable
|
||||
private fun EmptyLists(onNewList: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 32.dp, vertical = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.lists_empty),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
FilledTonalButton(onClick = onNewList) {
|
||||
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.lists_empty_action))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The home top bar. There is no title — the launcher icon already says which app
|
||||
* this is. Settings is pinned at the right; the search action sits just left of it
|
||||
@@ -419,7 +499,7 @@ private fun SearchResults(
|
||||
}
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(results, key = { it.id }) { task ->
|
||||
items(results, key = { it.occurrenceKey }) { task ->
|
||||
UpcomingRow(task = task, onClick = { onOpenTask(task.taskId) })
|
||||
}
|
||||
}
|
||||
@@ -655,12 +735,25 @@ private fun SmartCard(count: SmartCount, modifier: Modifier = Modifier, onClick:
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 4.dp),
|
||||
)
|
||||
private fun SectionHeader(text: String, action: (@Composable () -> Unit)? = null) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
start = 28.dp,
|
||||
end = if (action == null) 28.dp else 20.dp,
|
||||
top = if (action == null) 16.dp else 12.dp,
|
||||
bottom = 4.dp,
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
action?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -4,21 +4,30 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
|
||||
import de.jeanlucmakiola.floret.time.DayWindow
|
||||
import de.jeanlucmakiola.agendula.domain.SmartList
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFiltering
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.ZoneId
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* What a list write failed at. The screens turn this into wording; the view
|
||||
* models stay free of resources.
|
||||
*/
|
||||
enum class ListWriteFailure { SAVE, DELETE }
|
||||
|
||||
data class ListOverview(val list: TaskList, val openCount: Int)
|
||||
data class AccountGroup(val accountName: String, val lists: List<ListOverview>)
|
||||
data class SmartCount(val smart: SmartList, val count: Int)
|
||||
@@ -44,7 +53,7 @@ private const val UPCOMING_PREVIEW = 3
|
||||
/** The home overview: smart lists with live counts, then user lists by account. */
|
||||
@HiltViewModel
|
||||
class ListsViewModel @Inject constructor(
|
||||
repository: TasksRepository,
|
||||
private val repository: TasksRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
val state: StateFlow<ListsUiState> =
|
||||
@@ -56,7 +65,7 @@ class ListsViewModel @Inject constructor(
|
||||
repository.tasks(TaskFilter.Smart(SmartList.COMPLETED)),
|
||||
) { lists, openTasks, completedTasks ->
|
||||
buildContent(lists, openTasks, completedTasks) as ListsUiState
|
||||
}.catch { emit(ListsUiState.Failure) }
|
||||
}.recoveringFromProviderFailure { ListsUiState.Failure }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListsUiState.Loading)
|
||||
|
||||
private fun buildContent(
|
||||
@@ -112,4 +121,22 @@ class ListsViewModel @Inject constructor(
|
||||
allTasks = openTasks + completedTasks,
|
||||
)
|
||||
}
|
||||
|
||||
private val _writeFailure = MutableStateFlow<ListWriteFailure?>(null)
|
||||
|
||||
/** Set when a list write is refused; the screen shows it and clears it. */
|
||||
val writeFailure: StateFlow<ListWriteFailure?> = _writeFailure.asStateFlow()
|
||||
|
||||
fun clearWriteFailure() { _writeFailure.value = null }
|
||||
|
||||
/**
|
||||
* Create a device-only list. The lists flow picks it up on the store change;
|
||||
* a refusal (External mode, a provider that says no) surfaces through
|
||||
* [writeFailure] rather than vanishing, because the sheet has already closed.
|
||||
*/
|
||||
fun createList(name: String, color: Int) = viewModelScope.launch {
|
||||
if (name.isBlank()) return@launch
|
||||
runCatching { repository.createLocalList(name.trim(), color) }
|
||||
.onFailure { _writeFailure.value = ListWriteFailure.SAVE }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
package de.jeanlucmakiola.agendula.ui.permission
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus
|
||||
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.buffer
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class PermissionUiState(
|
||||
@@ -20,23 +32,51 @@ data class PermissionUiState(
|
||||
* Gates app entry: is a tasks provider installed, and do we hold its permissions?
|
||||
* The Composable owns the actual permission-launcher and store intents; this VM
|
||||
* supplies the [status] and the exact permission strings to ask for.
|
||||
*
|
||||
* In the default Own mode this gate never appears at all — the store is our own
|
||||
* Room database, so there is nothing to install and nothing to grant. It exists
|
||||
* for External mode, which also makes it the only screen an External user can
|
||||
* reach once their provider app stops answering: hence [useOwnStore].
|
||||
*/
|
||||
@HiltViewModel
|
||||
class PermissionViewModel @Inject constructor(
|
||||
private val repository: TasksRepository,
|
||||
private val providerResolver: ProviderResolver,
|
||||
private val prefs: SettingsPrefs,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(PermissionUiState())
|
||||
val state: StateFlow<PermissionUiState> = _state.asStateFlow()
|
||||
private val refreshes = MutableStateFlow(0)
|
||||
|
||||
init { refresh() }
|
||||
// Re-evaluated when the *resolver's* mode lands, not when the preference is
|
||||
// written: anything read in between still answers for the store we just left.
|
||||
// Conflated, as everywhere else this signal is bridged: only the latest mode
|
||||
// matters, and a full buffer drops it rather than the ones it supersedes.
|
||||
private val modeChanges: Flow<Unit> = callbackFlow {
|
||||
trySend(Unit)
|
||||
val handle = providerResolver.onModeChanged { trySend(Unit) }
|
||||
awaitClose { handle.close() }
|
||||
}.buffer(Channel.CONFLATED)
|
||||
|
||||
val state: StateFlow<PermissionUiState> =
|
||||
combine(refreshes, modeChanges) { _, _ -> currentState() }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), currentState())
|
||||
|
||||
/** Re-read provider + permission state (call after returning from a request). */
|
||||
fun refresh() {
|
||||
fun refresh() = refreshes.update { it + 1 }
|
||||
|
||||
/**
|
||||
* Leave a store this device can no longer read. The provider app can be
|
||||
* uninstalled, or its permission revoked, after External was chosen — and the
|
||||
* gate is then the only screen reachable, Settings included. Our own store
|
||||
* always reads, so it is the way out.
|
||||
*/
|
||||
fun useOwnStore() = viewModelScope.launch { prefs.setStorageMode(StorageMode.OWN) }
|
||||
|
||||
private fun currentState(): PermissionUiState {
|
||||
val provider = providerResolver.resolve()
|
||||
_state.value = PermissionUiState(
|
||||
return PermissionUiState(
|
||||
status = repository.providerStatus(),
|
||||
// Null in OWN mode, where there is no provider and nothing to grant.
|
||||
permissionsToRequest = provider
|
||||
?.let { listOf(it.readPermission, it.writePermission) }
|
||||
.orEmpty(),
|
||||
|
||||
@@ -43,11 +43,13 @@ import androidx.compose.material.icons.filled.Gavel
|
||||
import androidx.compose.material.icons.filled.Language
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.Translate
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material.icons.rounded.AccountTree
|
||||
import androidx.compose.material.icons.rounded.Circle
|
||||
import androidx.compose.material.icons.rounded.Flag
|
||||
import androidx.compose.material.icons.rounded.Percent
|
||||
import androidx.compose.material.icons.rounded.Storage
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -55,7 +57,6 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -74,17 +75,14 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||
import de.jeanlucmakiola.agendula.ui.export.ExportScreen
|
||||
import de.jeanlucmakiola.floret.components.AboutCard
|
||||
import de.jeanlucmakiola.floret.components.AboutLink
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.LanguagePickerRow
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
@@ -92,12 +90,26 @@ import de.jeanlucmakiola.agendula.ui.common.ReminderLeadPicker
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||
import de.jeanlucmakiola.floret.locale.AppLanguage
|
||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
|
||||
import de.jeanlucmakiola.agendula.ui.common.OnResume
|
||||
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel
|
||||
|
||||
/** The settings sub-screens reached from the hub's category rows. */
|
||||
private enum class SettingsSection { Appearance, TaskForm, Reminders }
|
||||
private enum class SettingsSection {
|
||||
Appearance,
|
||||
TaskForm,
|
||||
Reminders,
|
||||
Storage,
|
||||
Export,
|
||||
;
|
||||
|
||||
/** Where back goes: Export is opened from Storage, not from the hub. */
|
||||
val parent: SettingsSection?
|
||||
get() = if (this == Export) Storage else null
|
||||
}
|
||||
|
||||
/**
|
||||
* Token-based accent for a leading icon chip (container / on-container pair),
|
||||
@@ -123,7 +135,7 @@ fun SettingsScreen(
|
||||
|
||||
// Inside a sub-screen, system back (button or gesture) returns to the hub
|
||||
// rather than popping the whole Settings destination to the lists overview.
|
||||
BackHandler(enabled = section != null) { section = null }
|
||||
BackHandler(enabled = section != null) { section = section?.parent }
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
@@ -141,6 +153,19 @@ fun SettingsScreen(
|
||||
SlideInSection(visible = section == SettingsSection.Reminders) {
|
||||
RemindersScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
||||
}
|
||||
// Storage stays composed under Export, so the deeper screen slides over it.
|
||||
val storageOpen = section == SettingsSection.Storage ||
|
||||
section?.parent == SettingsSection.Storage
|
||||
SlideInSection(visible = storageOpen) {
|
||||
StorageScreen(
|
||||
viewModel = viewModel,
|
||||
onOpenExport = { section = SettingsSection.Export },
|
||||
onBack = { section = null },
|
||||
)
|
||||
}
|
||||
SlideInSection(visible = section == SettingsSection.Export) {
|
||||
ExportScreen(onBack = { section = SettingsSection.Storage })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,19 +235,77 @@ private fun SettingsHub(
|
||||
leading = { CategoryIcon(Icons.Default.Notifications, ChipAccent.Primary) },
|
||||
onClick = { onOpenSection(SettingsSection.Reminders) },
|
||||
)
|
||||
LanguagePickerRow(
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_section_storage),
|
||||
summary = stringResource(R.string.settings_storage_subtitle),
|
||||
position = Position.Middle,
|
||||
title = stringResource(R.string.settings_language),
|
||||
autoLabel = stringResource(R.string.settings_language_auto),
|
||||
localesConfig = R.xml.locales_config,
|
||||
leading = { CategoryIcon(Icons.Default.Language, ChipAccent.Neutral) },
|
||||
leading = { CategoryIcon(Icons.Rounded.Storage, ChipAccent.Neutral) },
|
||||
onClick = { onOpenSection(SettingsSection.Storage) },
|
||||
)
|
||||
LanguageRow(position = Position.Middle)
|
||||
ReportProblemRow(position = Position.Bottom)
|
||||
|
||||
AppVersionText()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The app-language row. Deliberately not floret-kit's `LanguagePickerRow`: the
|
||||
* picker it opens carries a "Help translate" header, and inviting contributions
|
||||
* right where a user goes looking for their language is app-specific framing,
|
||||
* not a family primitive. Everything else matches that recipe.
|
||||
*/
|
||||
@Composable
|
||||
private fun LanguageRow(position: Position) {
|
||||
val context = LocalContext.current
|
||||
// Setting a locale recreates the activity; mirror the choice locally so the
|
||||
// row updates instantly even before the recreation lands.
|
||||
var current by remember { mutableStateOf(AppLanguage.currentTag()) }
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
|
||||
// null = follow the system; the rest are BCP-47 tags from locales_config.xml.
|
||||
val options = remember { listOf<String?>(null) + AppLanguage.supportedTags(context, R.xml.locales_config) }
|
||||
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_language),
|
||||
summary = languageLabel(current),
|
||||
position = position,
|
||||
leading = { CategoryIcon(Icons.Default.Language, ChipAccent.Neutral) },
|
||||
onClick = { showDialog = true },
|
||||
)
|
||||
|
||||
if (showDialog) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_language),
|
||||
predictiveBack = true,
|
||||
options = options,
|
||||
selected = current,
|
||||
label = { languageLabel(it) },
|
||||
onSelect = {
|
||||
current = it
|
||||
AppLanguage.apply(it)
|
||||
},
|
||||
onDismiss = { showDialog = false },
|
||||
// Invite contributions right where users pick their language.
|
||||
header = {
|
||||
val translateUrl = stringResource(R.string.about_translate_url)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_translate),
|
||||
summary = stringResource(R.string.settings_translate_hint),
|
||||
position = Position.Alone,
|
||||
leading = { CategoryIcon(Icons.Default.Translate, ChipAccent.Neutral) },
|
||||
onClick = { openUrl(context, translateUrl) },
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun languageLabel(tag: String?): String =
|
||||
if (tag == null) stringResource(R.string.settings_language_auto) else AppLanguage.displayName(tag)
|
||||
|
||||
/** Opens the project's issue tracker; no data leaves the device until submitted. */
|
||||
@Composable
|
||||
private fun ReportProblemRow(position: Position) {
|
||||
@@ -483,10 +566,10 @@ private fun RemindersScreen(
|
||||
if (showOffset) {
|
||||
ReminderLeadPicker(
|
||||
title = stringResource(R.string.settings_default_reminder),
|
||||
selected = ReminderOverride.Minutes(state.settings.reminderLeadMinutes),
|
||||
selected = ReminderOverride.Minutes(listOf(state.settings.reminderLeadMinutes)),
|
||||
allowInherit = false,
|
||||
allowNone = false,
|
||||
onSelect = { if (it is ReminderOverride.Minutes) viewModel.setReminderLeadMinutes(it.minutes) },
|
||||
onSelect = { if (it is ReminderOverride.Minutes) viewModel.setReminderLeadMinutes(it.minutes.first()) },
|
||||
onDismiss = { showOffset = false },
|
||||
)
|
||||
}
|
||||
@@ -504,14 +587,8 @@ private fun RemindersScreen(
|
||||
}
|
||||
|
||||
/** The stored override for [listId], as a picker choice (absent → inherit). */
|
||||
private fun listOverrideChoice(state: SettingsUiState, listId: Long): ReminderOverride {
|
||||
val map = state.settings.perListReminderOverride
|
||||
return when {
|
||||
!map.containsKey(listId) -> ReminderOverride.Inherit
|
||||
map[listId] == null -> ReminderOverride.None
|
||||
else -> ReminderOverride.Minutes(map.getValue(listId)!!)
|
||||
}
|
||||
}
|
||||
private fun listOverrideChoice(state: SettingsUiState, listId: Long): ReminderOverride =
|
||||
state.settings.perListReminderOverride.reminderOverrideFor(listId)
|
||||
|
||||
/** Row summary for a list: its override, or the inherited global default. */
|
||||
@Composable
|
||||
@@ -519,7 +596,7 @@ private fun listOverrideSummary(choice: ReminderOverride, globalDefault: Int): S
|
||||
ReminderOverride.Inherit ->
|
||||
stringResource(R.string.settings_list_reminder_inherits, reminderLeadTimeLabel(globalDefault))
|
||||
ReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||
is ReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes)
|
||||
is ReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes.first())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -623,15 +700,10 @@ private fun rememberExactAlarmAllowed(context: Context): Boolean {
|
||||
context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms(),
|
||||
)
|
||||
}
|
||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||
DisposableEffect(lifecycle) {
|
||||
val obs = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
allowed = context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms()
|
||||
}
|
||||
OnResume {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
allowed = context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms()
|
||||
}
|
||||
lifecycle.addObserver(obs)
|
||||
onDispose { lifecycle.removeObserver(obs) }
|
||||
}
|
||||
return allowed
|
||||
}
|
||||
|
||||
@@ -3,18 +3,27 @@ package de.jeanlucmakiola.agendula.ui.settings
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.agendula.data.prefs.Settings
|
||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
||||
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.TaskProvider
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -23,6 +32,22 @@ data class SettingsUiState(
|
||||
val lists: List<TaskList> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* The storage half of Settings: which store is active, and what picking the
|
||||
* other one would mean on this device.
|
||||
*
|
||||
* Kept apart from [SettingsUiState] because that one is collected for the whole
|
||||
* Activity lifetime to drive the theme, and re-probing PackageManager on every
|
||||
* theme emission would be work for nothing.
|
||||
*/
|
||||
data class StorageUiState(
|
||||
val mode: StorageMode,
|
||||
/** The external provider installed here, or null when there is none to pick. */
|
||||
val external: TaskProvider? = null,
|
||||
/** That provider's own app name, for a row that names what it is switching to. */
|
||||
val externalLabel: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Drives both the Settings screen and the app theme (MainActivity collects the
|
||||
* same instance), so a theme change applies app-wide at once.
|
||||
@@ -30,14 +55,48 @@ data class SettingsUiState(
|
||||
@HiltViewModel
|
||||
class SettingsViewModel @Inject constructor(
|
||||
private val prefs: SettingsPrefs,
|
||||
private val resolver: ProviderResolver,
|
||||
private val environment: ProviderEnvironment,
|
||||
@IoDispatcher io: CoroutineDispatcher,
|
||||
repository: TasksRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
// MainActivity collects this for the theme, above the permission gate and for
|
||||
// the whole Activity lifetime — so the list flow must survive the pre-grant
|
||||
// SecurityException and recover once permission is given, not die for good.
|
||||
val state: StateFlow<SettingsUiState> =
|
||||
combine(prefs.settings, repository.taskLists().catch { emit(emptyList()) }) { settings, lists ->
|
||||
combine(
|
||||
prefs.settings,
|
||||
repository.taskLists().recoveringFromProviderFailure { emptyList() },
|
||||
) { settings, lists ->
|
||||
SettingsUiState(settings, lists)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState())
|
||||
|
||||
// Bumped to re-probe the device; installing a provider or granting its
|
||||
// permission happens outside the app, so nothing else would emit.
|
||||
private val providerProbe = MutableStateFlow(0)
|
||||
|
||||
// Null until the first emission lands: the mode comes from DataStore and the
|
||||
// rest from PackageManager, off the main thread, so any seeded default would
|
||||
// name the wrong store for the first frames. flowOn, because every field here
|
||||
// costs a PackageManager lookup or a permission check.
|
||||
val storage: StateFlow<StorageUiState?> =
|
||||
combine(prefs.storageMode, providerProbe) { stored, _ ->
|
||||
val external = resolver.resolveExternal()
|
||||
StorageUiState(
|
||||
// No stored choice is the normal state; show what autoMode resolves
|
||||
// to rather than a default that may not be the store in use.
|
||||
mode = stored ?: resolver.autoMode(),
|
||||
external = external,
|
||||
externalLabel = external?.packageName?.let(environment::appLabel),
|
||||
)
|
||||
}.flowOn(io).stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
/** Re-read the device's provider state, after a permission request or a resume. */
|
||||
fun refreshStorage() = providerProbe.update { it + 1 }
|
||||
|
||||
fun setStorageMode(mode: StorageMode) = viewModelScope.launch { prefs.setStorageMode(mode) }
|
||||
|
||||
fun setThemeMode(mode: ThemeMode) = viewModelScope.launch { prefs.setThemeMode(mode) }
|
||||
fun setDynamicColor(enabled: Boolean) = viewModelScope.launch { prefs.setDynamicColor(enabled) }
|
||||
fun setDefaultList(id: Long?) = viewModelScope.launch { prefs.setDefaultListId(id) }
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
package de.jeanlucmakiola.agendula.ui.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Apps
|
||||
import androidx.compose.material.icons.rounded.PhoneAndroid
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TaskProvider
|
||||
import de.jeanlucmakiola.agendula.ui.common.OnResume
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
|
||||
/**
|
||||
* Where the tasks live: the store picker the resolver's `autoMode()` has always
|
||||
* assumed, plus the way out of a store that lives in our own private storage.
|
||||
*/
|
||||
@Composable
|
||||
internal fun StorageScreen(
|
||||
viewModel: SettingsViewModel,
|
||||
onOpenExport: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val storage by viewModel.storage.collectAsStateWithLifecycle()
|
||||
var showPicker by remember { mutableStateOf(false) }
|
||||
var denied by remember { mutableStateOf(false) }
|
||||
|
||||
// The mode is committed only once the grant is in — switching first drops the
|
||||
// user on the app-wide permission gate.
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestMultiplePermissions(),
|
||||
) { grants ->
|
||||
viewModel.refreshStorage()
|
||||
val granted = grants.isNotEmpty() && grants.values.all { it }
|
||||
denied = !granted
|
||||
if (granted) viewModel.setStorageMode(StorageMode.EXTERNAL)
|
||||
}
|
||||
|
||||
// A provider can be installed, or its permission revoked, while we're away.
|
||||
// Deliberately does not clear [denied]: this fires on returning from the
|
||||
// permission dialog too, and would wipe the refusal before it is read.
|
||||
OnResume { viewModel.refreshStorage() }
|
||||
|
||||
CollapsingScaffold(title = stringResource(R.string.settings_section_storage), onBack = onBack) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_task_store),
|
||||
summary = storage?.let { storeLabel(it) },
|
||||
position = Position.Top,
|
||||
// Nothing to pick until the stored mode has landed; opening the picker
|
||||
// on the seeded state would offer the wrong store as the current one.
|
||||
onClick = storage?.let {
|
||||
{
|
||||
denied = false
|
||||
showPicker = true
|
||||
}
|
||||
},
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_export),
|
||||
summary = stringResource(R.string.settings_export_hint),
|
||||
position = Position.Bottom,
|
||||
onClick = onOpenExport,
|
||||
)
|
||||
|
||||
if (denied) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_store_permission_denied),
|
||||
summary = stringResource(R.string.settings_store_permission_denied_hint),
|
||||
position = Position.Alone,
|
||||
onClick = { context.openAppSettings() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
storage?.let { state ->
|
||||
if (showPicker) {
|
||||
StorePicker(
|
||||
storage = state,
|
||||
onSelect = { mode ->
|
||||
val external = state.external
|
||||
if (mode == StorageMode.EXTERNAL && external != null) {
|
||||
// Asked even when the grant looks held: an already-granted
|
||||
// request returns at once, a stale belief would strand them.
|
||||
permissionLauncher.launch(
|
||||
arrayOf(external.readPermission, external.writePermission),
|
||||
)
|
||||
} else {
|
||||
viewModel.setStorageMode(mode)
|
||||
}
|
||||
},
|
||||
onDismiss = { showPicker = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The two stores, as rows. External is offered only when a provider is actually
|
||||
* installed — dimmed and inert otherwise, because a mode with nothing behind it
|
||||
* empties the app.
|
||||
*/
|
||||
@Composable
|
||||
private fun StorePicker(
|
||||
storage: StorageUiState,
|
||||
onSelect: (StorageMode) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
FullScreenPicker(title = stringResource(R.string.settings_task_store), onDismiss = onDismiss) {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_task_store_hint),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
val select: (StorageMode) -> Unit = { chosen ->
|
||||
onSelect(chosen)
|
||||
onDismiss()
|
||||
}
|
||||
val external = storage.external
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_store_own),
|
||||
summary = stringResource(R.string.settings_store_own_hint),
|
||||
position = Position.Top,
|
||||
selected = storage.mode == StorageMode.OWN,
|
||||
leading = { Icon(Icons.Rounded.PhoneAndroid, contentDescription = null) },
|
||||
trailing = if (storage.mode == StorageMode.OWN) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = { select(StorageMode.OWN) },
|
||||
)
|
||||
GroupedRow(
|
||||
title = externalTitle(external, storage.externalLabel),
|
||||
summary = stringResource(
|
||||
if (external == null) {
|
||||
R.string.settings_store_external_missing
|
||||
} else {
|
||||
R.string.settings_store_external_hint
|
||||
},
|
||||
),
|
||||
position = Position.Bottom,
|
||||
selected = storage.mode == StorageMode.EXTERNAL,
|
||||
dimmed = external == null,
|
||||
leading = { Icon(Icons.Rounded.Apps, contentDescription = null) },
|
||||
trailing = if (storage.mode == StorageMode.EXTERNAL) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = if (external != null) ({ select(StorageMode.EXTERNAL) }) else null,
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
/** The active store, named the way the picker names it. */
|
||||
@Composable
|
||||
private fun storeLabel(storage: StorageUiState): String = when (storage.mode) {
|
||||
StorageMode.OWN -> stringResource(R.string.settings_store_own)
|
||||
StorageMode.EXTERNAL -> externalTitle(storage.external, storage.externalLabel)
|
||||
}
|
||||
|
||||
/** The provider's own app name, its authority, or the generic wording. */
|
||||
@Composable
|
||||
private fun externalTitle(provider: TaskProvider?, label: String?): String =
|
||||
label ?: provider?.authority ?: stringResource(R.string.settings_store_external)
|
||||
|
||||
private fun Context.openAppSettings() {
|
||||
runCatching {
|
||||
startActivity(
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, "package:$packageName".toUri()),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ import androidx.compose.material.icons.rounded.Add
|
||||
import androidx.compose.material.icons.rounded.Check
|
||||
import androidx.compose.material.icons.rounded.Checklist
|
||||
import androidx.compose.material.icons.rounded.Delete
|
||||
import androidx.compose.material.icons.rounded.Edit
|
||||
import androidx.compose.material.icons.rounded.ExpandMore
|
||||
import androidx.compose.material.icons.rounded.Flag
|
||||
import androidx.compose.material3.Checkbox
|
||||
@@ -97,7 +98,12 @@ import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||
import de.jeanlucmakiola.agendula.domain.TaskSection
|
||||
import de.jeanlucmakiola.agendula.domain.TaskSections
|
||||
import de.jeanlucmakiola.agendula.ui.common.priorityAccent
|
||||
import de.jeanlucmakiola.agendula.ui.lists.ListEditorSheet
|
||||
import de.jeanlucmakiola.agendula.ui.lists.ListWriteFailure
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.SnackChip
|
||||
import de.jeanlucmakiola.floret.components.SnackChipHeight
|
||||
import de.jeanlucmakiola.floret.components.SnackChipMargin
|
||||
import de.jeanlucmakiola.floret.time.formatDateTimeCompact
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
@@ -123,8 +129,22 @@ fun TaskListScreen(
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
||||
val content = state as? TaskListUiState.Content
|
||||
val listName = content?.listName
|
||||
val list = content?.list
|
||||
val listName = list?.name
|
||||
val listId = (filter as? TaskFilter.OfList)?.listId
|
||||
// Editing is offered for a device-only list. A collection that belongs to an
|
||||
// account is the server's to rename or remove, not ours.
|
||||
var editingList by rememberSaveable { mutableStateOf(false) }
|
||||
val listWriteFailure by viewModel.listWriteFailure.collectAsStateWithLifecycle()
|
||||
val listDeleted by viewModel.listDeleted.collectAsStateWithLifecycle()
|
||||
// The list this screen is about is gone; there is nothing left to show.
|
||||
LaunchedEffect(listDeleted) { if (listDeleted) onBack() }
|
||||
LaunchedEffect(listWriteFailure) {
|
||||
if (listWriteFailure != null) {
|
||||
delay(4_000)
|
||||
viewModel.clearListWriteFailure()
|
||||
}
|
||||
}
|
||||
// One add affordance, never two: a real list with the setting on gets a pinned
|
||||
// bottom quick-add bar; everything else (incl. smart lists, which have no single
|
||||
// target list) gets the floating "New task" button.
|
||||
@@ -159,6 +179,16 @@ fun TaskListScreen(
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (list != null && list.isLocal) {
|
||||
IconButton(onClick = { editingList = true }) {
|
||||
Icon(
|
||||
Icons.Rounded.Edit,
|
||||
contentDescription = stringResource(R.string.list_edit_title),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
},
|
||||
@@ -189,61 +219,50 @@ fun TaskListScreen(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(start = 16.dp, bottom = inner.calculateBottomPadding() + 16.dp)
|
||||
.height(56.dp),
|
||||
.padding(
|
||||
start = SnackChipMargin,
|
||||
bottom = inner.calculateBottomPadding() + SnackChipMargin,
|
||||
)
|
||||
.height(SnackChipHeight),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
UndoChip(
|
||||
visible = undoTarget != null,
|
||||
onUndo = {
|
||||
undoTarget?.let { viewModel.undoDelete(it.taskId) }
|
||||
undoTarget = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A compact floating "snackchip" for an undoable delete — a rounded pill (not a
|
||||
* full-width snackbar) sized to its content, sliding up from the bottom centre.
|
||||
*/
|
||||
@Composable
|
||||
private fun UndoChip(visible: Boolean, onUndo: () -> Unit, modifier: Modifier = Modifier) {
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = slideInVertically { it } + fadeIn(),
|
||||
exit = slideOutVertically { it } + fadeOut(),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
shape = RoundedCornerShape(50),
|
||||
shadowElevation = 6.dp,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 20.dp, end = 8.dp, top = 6.dp, bottom = 6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.task_deleted),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
TextButton(
|
||||
onClick = onUndo,
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 6.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.undo),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
// One chip, one anchor: the undo receipt takes precedence, and a
|
||||
// refused list write reports itself once the undo window is clear.
|
||||
val failure = listWriteFailure
|
||||
if (undoTarget != null || failure == null) {
|
||||
SnackChip(
|
||||
visible = undoTarget != null,
|
||||
message = stringResource(R.string.task_deleted),
|
||||
actionLabel = stringResource(R.string.undo),
|
||||
onAction = {
|
||||
undoTarget?.let { viewModel.undoDelete(it.taskId) }
|
||||
undoTarget = null
|
||||
},
|
||||
)
|
||||
} else {
|
||||
SnackChip(
|
||||
visible = true,
|
||||
message = stringResource(listWriteFailureMessage(failure)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (editingList && list != null) {
|
||||
ListEditorSheet(
|
||||
initial = list,
|
||||
onSave = { name, color -> viewModel.updateList(list.id, name, color) },
|
||||
onDismiss = { editingList = false },
|
||||
onDelete = { viewModel.deleteList(list.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Wording for a refused list write. */
|
||||
private fun listWriteFailureMessage(failure: ListWriteFailure): Int = when (failure) {
|
||||
ListWriteFailure.SAVE -> R.string.list_save_failed
|
||||
ListWriteFailure.DELETE -> R.string.list_delete_failed
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@@ -806,20 +825,20 @@ private fun SubtaskExpandButton(expanded: Boolean, onToggle: () -> Unit) {
|
||||
|
||||
/** A visual row in a flattened section run: a top-level task, or one of its subtasks. */
|
||||
private sealed interface ListRow {
|
||||
val key: Long
|
||||
val key: String
|
||||
|
||||
data class Parent(val task: Task, val expandable: Boolean, val expanded: Boolean) : ListRow {
|
||||
override val key: Long get() = task.taskId
|
||||
override val key: String get() = task.occurrenceKey
|
||||
}
|
||||
|
||||
data class Sub(val task: Task) : ListRow {
|
||||
override val key: Long get() = task.taskId
|
||||
override val key: String get() = task.occurrenceKey
|
||||
}
|
||||
|
||||
/** The inline "add a subtask" row that closes an expanded group. */
|
||||
data class AddSub(val parent: Task) : ListRow {
|
||||
// Negative so it never collides with a real (positive) provider task id.
|
||||
override val key: Long get() = -parent.taskId
|
||||
// Prefixed so it never collides with the task row it belongs to.
|
||||
override val key: String get() = "add-${parent.occurrenceKey}"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,14 +5,17 @@ import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.agendula.ui.lists.ListWriteFailure
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
@@ -28,13 +31,13 @@ sealed interface TaskListUiState {
|
||||
data object Failure : TaskListUiState
|
||||
|
||||
/**
|
||||
* [listName] is the real list's name when the filter is a
|
||||
* [TaskFilter.OfList] (for the top-bar title), `null` for smart lists —
|
||||
* the screen falls back to the smart label in that case.
|
||||
* [list] is the real list when the filter is a [TaskFilter.OfList] — it
|
||||
* titles the bar and backs the edit action — and `null` for smart lists,
|
||||
* where the screen falls back to the smart label.
|
||||
*/
|
||||
data class Content(
|
||||
val tasks: List<Task>,
|
||||
val listName: String? = null,
|
||||
val list: TaskList? = null,
|
||||
/** Whether the inline "add a subtask" row shows on expanded groups (M5 setting). */
|
||||
val showAddSubtaskRow: Boolean = true,
|
||||
/** Whether a real list uses the bottom quick-add bar instead of the FAB. */
|
||||
@@ -65,8 +68,8 @@ class TaskListViewModel @Inject constructor(
|
||||
val tasks = repository.tasks(f)
|
||||
val content: kotlinx.coroutines.flow.Flow<TaskListUiState> = when (f) {
|
||||
is TaskFilter.OfList ->
|
||||
combine(tasks, repository.taskLists()) { list, lists ->
|
||||
TaskListUiState.Content(list, lists.firstOrNull { it.id == f.listId }?.name)
|
||||
combine(tasks, repository.taskLists()) { rows, lists ->
|
||||
TaskListUiState.Content(rows, lists.firstOrNull { it.id == f.listId })
|
||||
}
|
||||
is TaskFilter.Smart ->
|
||||
tasks.map { TaskListUiState.Content(it) }
|
||||
@@ -87,7 +90,10 @@ class TaskListViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
.onStart { emit(TaskListUiState.Loading) }
|
||||
.catch { emit(TaskListUiState.Failure) }
|
||||
// Recover rather than terminate: a provider hiccup (mid-update,
|
||||
// permission not yet granted) shows Failure but keeps retrying,
|
||||
// so the screen heals itself instead of staying stuck.
|
||||
.recoveringFromProviderFailure { TaskListUiState.Failure }
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskListUiState.Loading)
|
||||
|
||||
@@ -112,6 +118,9 @@ class TaskListViewModel @Inject constructor(
|
||||
combine(ids.map { id -> repository.subtasks(id).map { id to it } }) { it.toMap() }
|
||||
}
|
||||
}
|
||||
// Without this an exception here escapes stateIn's coroutine, past
|
||||
// viewModelScope's SupervisorJob, and crashes the process.
|
||||
.recoveringFromProviderFailure { emptyMap() }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
|
||||
|
||||
/** The screen reports which expanded parents need their children fetched. */
|
||||
@@ -120,7 +129,7 @@ class TaskListViewModel @Inject constructor(
|
||||
fun bind(taskFilter: TaskFilter) { filter.value = taskFilter }
|
||||
|
||||
fun toggleComplete(task: Task) = viewModelScope.launch {
|
||||
runCatching { repository.setCompleted(task.taskId, !task.isCompleted) }
|
||||
runCatching { repository.setCompleted(task.taskId, task.occurrenceStart, !task.isCompleted) }
|
||||
}
|
||||
|
||||
/** Swipe-delete: hide the row now; the screen's snackbar commits or restores it. */
|
||||
@@ -149,6 +158,36 @@ class TaskListViewModel @Inject constructor(
|
||||
runCatching { repository.createTask(TaskForm(title = title, listId = listId)) }
|
||||
}
|
||||
|
||||
private val _listWriteFailure = MutableStateFlow<ListWriteFailure?>(null)
|
||||
|
||||
/** Set when a list write is refused; the screen shows it and clears it. */
|
||||
val listWriteFailure: StateFlow<ListWriteFailure?> = _listWriteFailure.asStateFlow()
|
||||
|
||||
private val _listDeleted = MutableStateFlow(false)
|
||||
|
||||
/** Flips once the list this screen shows is really gone, so it can leave. */
|
||||
val listDeleted: StateFlow<Boolean> = _listDeleted.asStateFlow()
|
||||
|
||||
fun clearListWriteFailure() { _listWriteFailure.value = null }
|
||||
|
||||
/** Rename / recolour the list this screen is showing. */
|
||||
fun updateList(listId: Long, name: String, color: Int) = viewModelScope.launch {
|
||||
if (name.isBlank()) return@launch
|
||||
runCatching { repository.updateList(listId, name.trim(), color) }
|
||||
.onFailure { _listWriteFailure.value = ListWriteFailure.SAVE }
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the list **and its tasks**. The screen navigates away on
|
||||
* [listDeleted], not on the call — leaving first would strand a refusal on a
|
||||
* screen that no longer exists.
|
||||
*/
|
||||
fun deleteList(listId: Long) = viewModelScope.launch {
|
||||
runCatching { repository.deleteList(listId) }
|
||||
.onSuccess { _listDeleted.value = true }
|
||||
.onFailure { _listWriteFailure.value = ListWriteFailure.DELETE }
|
||||
}
|
||||
|
||||
/** Inline "add subtask" from an expanded list group — files it under [parent]. */
|
||||
fun quickAddSubtask(parent: Task, title: String) = viewModelScope.launch {
|
||||
if (title.isBlank() || parent.listId <= 0L) return@launch
|
||||
|
||||
@@ -122,7 +122,32 @@
|
||||
<string name="lists_header">Lists</string>
|
||||
<string name="new_task">New task</string>
|
||||
<string name="lists_failure">Could not read your tasks.</string>
|
||||
<string name="lists_empty">No task lists yet. Add one in your tasks app or with the + button.</string>
|
||||
<string name="lists_empty">No task lists yet.</string>
|
||||
<string name="lists_empty_action">Create a list</string>
|
||||
|
||||
<!-- Task lists: create, edit, delete -->
|
||||
<string name="list_add">New list</string>
|
||||
<string name="list_new_title">New list</string>
|
||||
<string name="list_edit_title">Edit list</string>
|
||||
<string name="list_name_hint">List name</string>
|
||||
<string name="list_color">Colour</string>
|
||||
<string name="list_delete">Delete list</string>
|
||||
<string name="list_save_failed">Could not save the list.</string>
|
||||
<string name="list_delete_failed">Could not delete the list.</string>
|
||||
<string name="list_delete_confirm_title">Delete list?</string>
|
||||
<string name="list_delete_confirm_message">“%1$s” and all of its tasks will be deleted. This can\'t be undone.</string>
|
||||
<string name="list_color_mauve">Mauve</string>
|
||||
<string name="list_color_red">Red</string>
|
||||
<string name="list_color_orange">Orange</string>
|
||||
<string name="list_color_amber">Amber</string>
|
||||
<string name="list_color_olive">Olive</string>
|
||||
<string name="list_color_green">Green</string>
|
||||
<string name="list_color_teal">Teal</string>
|
||||
<string name="list_color_cyan">Cyan</string>
|
||||
<string name="list_color_blue">Blue</string>
|
||||
<string name="list_color_indigo">Indigo</string>
|
||||
<string name="list_color_purple">Purple</string>
|
||||
<string name="list_color_pink">Pink</string>
|
||||
<string name="smart_today">Today</string>
|
||||
<string name="smart_overdue">Overdue</string>
|
||||
<string name="smart_upcoming">Upcoming</string>
|
||||
@@ -190,13 +215,16 @@
|
||||
<string name="settings_about_logo_desc">Agendula app icon</string>
|
||||
<string name="settings_language">App language</string>
|
||||
<string name="settings_language_auto">System default</string>
|
||||
<string name="settings_translate">Help translate</string>
|
||||
<string name="settings_translate_hint">Add or improve a language on Weblate</string>
|
||||
<string name="settings_report_problem">Report a problem</string>
|
||||
<string name="settings_report_problem_hint">Open the issue tracker</string>
|
||||
<string name="about_source_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/agendula</string>
|
||||
<string name="about_source_url" translatable="false">https://codeberg.org/jlmakiola/agendula</string>
|
||||
<string name="crash_report_issue_title">Crash report</string>
|
||||
<string name="report_issue_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/agendula/issues/new</string>
|
||||
<string name="about_license_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/agendula/src/branch/main/LICENSE</string>
|
||||
<string name="report_issue_url" translatable="false">https://codeberg.org/jlmakiola/agendula/issues/new</string>
|
||||
<string name="about_license_url" translatable="false">https://codeberg.org/jlmakiola/agendula/src/branch/main/LICENSE</string>
|
||||
<string name="about_support_url" translatable="false">https://ko-fi.com/jeanlucmakiola</string>
|
||||
<string name="about_translate_url" translatable="false">https://weblate.dev.jeanlucmakiola.de/engage/agendula/</string>
|
||||
<string name="settings_theme">Theme</string>
|
||||
<string name="settings_theme_system">Follow system</string>
|
||||
<string name="settings_theme_light">Light</string>
|
||||
@@ -218,6 +246,37 @@
|
||||
<string name="settings_bottom_add_bar">Bottom quick-add bar</string>
|
||||
<string name="settings_bottom_add_bar_hint">Add tasks from a bar pinned to the bottom of a list, instead of the floating button</string>
|
||||
|
||||
<string name="onboarding_use_own_store">Use this device\'s storage instead</string>
|
||||
|
||||
<!-- Storage and export -->
|
||||
<string name="settings_section_storage">Storage</string>
|
||||
<string name="settings_storage_subtitle">Where tasks are kept, and export</string>
|
||||
<string name="settings_task_store">Task store</string>
|
||||
<string name="settings_task_store_hint">Each store keeps its own tasks. Switching does not move them across — export first if you want a copy.</string>
|
||||
<string name="settings_store_own">On this device</string>
|
||||
<string name="settings_store_own_hint">Agendula\'s own storage. Nothing else to install.</string>
|
||||
<string name="settings_store_external">Another task app</string>
|
||||
<string name="settings_store_external_hint">Share tasks with the app that syncs them</string>
|
||||
<string name="settings_store_external_missing">No compatible task app is installed</string>
|
||||
<string name="settings_store_permission_denied">Permission denied</string>
|
||||
<string name="settings_store_permission_denied_hint">The other app\'s tasks stay unreachable until you allow access. Tap to open app settings.</string>
|
||||
<string name="settings_export">Export tasks</string>
|
||||
<string name="settings_export_hint">Save your lists as iCalendar files</string>
|
||||
<string name="export_hint">One .ics file per list, readable by other task and calendar apps. The ticked lists go to a folder you pick, or into a single zip.</string>
|
||||
<string name="export_no_lists">No lists to export</string>
|
||||
<string name="export_to_folder">Save to a folder</string>
|
||||
<string name="export_to_zip">Save as a zip file</string>
|
||||
<string name="export_running">Exporting…</string>
|
||||
<plurals name="export_done">
|
||||
<item quantity="one">Exported %1$d list</item>
|
||||
<item quantity="other">Exported %1$d lists</item>
|
||||
</plurals>
|
||||
<string name="export_failed">The export could not be written</string>
|
||||
<string name="export_failed_folder">The chosen folder could not be opened</string>
|
||||
<string name="export_failed_read_only">The chosen folder is not writable</string>
|
||||
<string name="export_failed_create">A file could not be created in the chosen folder</string>
|
||||
<string name="export_failed_access">Access to the chosen location was lost</string>
|
||||
|
||||
<!-- Reminder lead times (custom amounts) -->
|
||||
<plurals name="reminder_minutes">
|
||||
<item quantity="one">%1$d minute before</item>
|
||||
|
||||
@@ -1,4 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<full-backup-content>
|
||||
<!-- No file-based backups; settings live in DataStore which is backed up by default. -->
|
||||
<!--
|
||||
Agendula's own task store. Room runs in WAL mode and Auto Backup copies
|
||||
files without checkpointing, so the `-wal` sidecar can hold writes the
|
||||
`.db` alone does not — all three go in together, and the app checkpoints
|
||||
on ON_STOP so a restore is consistent either way.
|
||||
|
||||
Naming any <include> makes everything else excluded by default, so the
|
||||
archived dmfs database (`tasks.db.imported`, kept one release as the
|
||||
import's rollback path) is already left out. An explicit <exclude> for it
|
||||
would be redundant *and* rejected — lint's FullBackupContent check errors
|
||||
on an exclude that sits under no included path.
|
||||
|
||||
Settings live in DataStore, which this exclusion now also covers, so its
|
||||
sharedpref file is listed back in.
|
||||
-->
|
||||
<include domain="database" path="agendula-tasks.db" />
|
||||
<include domain="database" path="agendula-tasks.db-wal" />
|
||||
<include domain="database" path="agendula-tasks.db-shm" />
|
||||
<include domain="file" path="datastore/" />
|
||||
</full-backup-content>
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<data-extraction-rules>
|
||||
<!--
|
||||
See backup_rules.xml: the WAL sidecars travel with the database, and
|
||||
naming any <include> makes everything else excluded by default — which is
|
||||
what keeps the archived `tasks.db.imported` out without an <exclude> that
|
||||
lint would reject.
|
||||
-->
|
||||
<cloud-backup>
|
||||
<!-- Allow DataStore backup, exclude nothing extra. -->
|
||||
<include domain="database" path="agendula-tasks.db" />
|
||||
<include domain="database" path="agendula-tasks.db-wal" />
|
||||
<include domain="database" path="agendula-tasks.db-shm" />
|
||||
<include domain="file" path="datastore/" />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<include domain="database" path="agendula-tasks.db" />
|
||||
<include domain="database" path="agendula-tasks.db-wal" />
|
||||
<include domain="database" path="agendula-tasks.db-shm" />
|
||||
<include domain="file" path="datastore/" />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package de.jeanlucmakiola.agendula.data.export
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* The export file name. The user picks the folder, so whatever comes out of here
|
||||
* is what they will be looking at in a file manager a year from now.
|
||||
*/
|
||||
class TaskExporterTest {
|
||||
|
||||
private fun name(listName: String, id: Long = 3L) = TaskExporter.fileNameFor(listName, id)
|
||||
|
||||
@Test
|
||||
fun `keeps a plain name readable`() {
|
||||
assertThat(name("Groceries")).isEqualTo("Groceries-3.ics")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replaces characters a filesystem would reject`() {
|
||||
// SAF can land on FAT32 (an SD card), where these are simply illegal.
|
||||
val result = name("Work / Home: notes?")
|
||||
assertThat(result).doesNotContain("/")
|
||||
assertThat(result).doesNotContain(":")
|
||||
assertThat(result).doesNotContain("?")
|
||||
assertThat(result).endsWith("-3.ics")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keeps the id so same-named lists cannot collide`() {
|
||||
// Two accounts may each have a list called "Personal"; without the id one
|
||||
// export would silently overwrite the other.
|
||||
assertThat(name("Personal", 1)).isNotEqualTo(name("Personal", 2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `falls back when the name has nothing usable in it`() {
|
||||
assertThat(name("///")).isEqualTo("list-3.ics")
|
||||
assertThat(name("")).isEqualTo("list-3.ics")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `does not leave dangling separators`() {
|
||||
assertThat(name(" Shopping ")).isEqualTo("Shopping-3.ics")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `caps the length`() {
|
||||
// Many filesystems stop at 255 bytes for a name; a pathological list title
|
||||
// should not be the thing that fails an export.
|
||||
assertThat(name("x".repeat(500)).length).isAtMost(80)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keeps non-latin names instead of blanking them`() {
|
||||
// isLetterOrDigit is Unicode-aware, so these survive rather than collapsing
|
||||
// to the "list" fallback.
|
||||
assertThat(name("Einkäufe")).isEqualTo("Einkäufe-3.ics")
|
||||
assertThat(name("買い物")).isEqualTo("買い物-3.ics")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* The storage-mode decision, which is the part of [ProviderResolver] with real
|
||||
* consequences: pick wrong for a returning user and the app opens on an empty
|
||||
* store where their tasks used to be.
|
||||
*/
|
||||
class ProviderResolverTest {
|
||||
|
||||
/**
|
||||
* A [ProviderEnvironment] with no Android in it.
|
||||
*
|
||||
* @param installed authority -> declaring package, i.e. what is on the device.
|
||||
* @param granted permissions this app currently holds.
|
||||
*/
|
||||
private class FakeEnvironment(
|
||||
val installed: Map<String, String> = emptyMap(),
|
||||
val granted: Set<String> = emptySet(),
|
||||
) : ProviderEnvironment {
|
||||
override fun packageDeclaring(authority: String): String? = installed[authority]
|
||||
override fun isGranted(permission: String): Boolean = permission in granted
|
||||
override fun appLabel(packageName: String): String? = packageName
|
||||
}
|
||||
|
||||
private val openTasks = ProviderResolver.EXTERNAL_CANDIDATES.first { it.authority == "org.dmfs.tasks" }
|
||||
|
||||
private fun resolver(
|
||||
installed: Map<String, String> = emptyMap(),
|
||||
granted: Set<String> = emptySet(),
|
||||
mode: StorageMode? = null,
|
||||
) = ProviderResolver(FakeEnvironment(installed, granted)).apply { storageMode = mode }
|
||||
|
||||
private val openTasksInstalled = mapOf("org.dmfs.tasks" to "org.dmfs.tasks")
|
||||
private val openTasksGranted = setOf(openTasks.readPermission, openTasks.writePermission)
|
||||
|
||||
@Nested
|
||||
inner class OwnStore {
|
||||
|
||||
@Test
|
||||
fun `is readable without anything installed or granted`() {
|
||||
// Guards a real regression: the reminder engine used to gate on
|
||||
// resolve() != null, which is exactly what OWN returns, so every
|
||||
// reminder was cleared the moment our own store became the default.
|
||||
assertThat(resolver(mode = StorageMode.OWN).canReadStore()).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolves to no provider at all`() {
|
||||
// Room has no authority and no ContentResolver, so there is nothing
|
||||
// here to resolve — which is the point. Callers that need to tell this
|
||||
// apart from "External, none installed" ask mode().
|
||||
val resolver = resolver(mode = StorageMode.OWN)
|
||||
assertThat(resolver.resolve()).isNull()
|
||||
assertThat(resolver.mode()).isEqualTo(StorageMode.OWN)
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class AutoMode {
|
||||
|
||||
@Test
|
||||
fun `a fresh install with nothing else present gets our own store`() {
|
||||
assertThat(resolver().autoMode()).isEqualTo(StorageMode.OWN)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an upgrading user who already granted OpenTasks stays on it`() {
|
||||
// Holding a dangerous permission means a previous version asked and they
|
||||
// agreed — the signature of an existing Posture A user. Sending them to
|
||||
// our empty bundled store would read as data loss.
|
||||
val resolver = resolver(installed = openTasksInstalled, granted = openTasksGranted)
|
||||
assertThat(resolver.autoMode()).isEqualTo(StorageMode.EXTERNAL)
|
||||
assertThat(resolver.resolve()?.authority).isEqualTo("org.dmfs.tasks")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `OpenTasks merely installed is not enough`() {
|
||||
// Someone who has OpenTasks for unrelated reasons, and never granted us
|
||||
// anything, has no data with us there. Our own store is right for them.
|
||||
assertThat(resolver(installed = openTasksInstalled).autoMode()).isEqualTo(StorageMode.OWN)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a half-granted external provider does not count`() {
|
||||
val resolver = resolver(
|
||||
installed = openTasksInstalled,
|
||||
granted = setOf(openTasks.readPermission),
|
||||
)
|
||||
assertThat(resolver.autoMode()).isEqualTo(StorageMode.OWN)
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class ExplicitChoice {
|
||||
|
||||
@Test
|
||||
fun `overrides the automatic answer in both directions`() {
|
||||
val wouldBeExternal = FakeEnvironment(openTasksInstalled, openTasksGranted)
|
||||
|
||||
val forcedOwn = ProviderResolver(wouldBeExternal).apply { storageMode = StorageMode.OWN }
|
||||
assertThat(forcedOwn.mode()).isEqualTo(StorageMode.OWN)
|
||||
assertThat(forcedOwn.resolve()).isNull()
|
||||
|
||||
val forcedExternal = ProviderResolver(FakeEnvironment()).apply { storageMode = StorageMode.EXTERNAL }
|
||||
assertThat(forcedExternal.mode()).isEqualTo(StorageMode.EXTERNAL)
|
||||
assertThat(forcedExternal.resolve()).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `external with no provider installed resolves to nothing`() {
|
||||
// Drives the "install a tasks provider" gate rather than silently
|
||||
// falling back to our own store behind the user's back.
|
||||
assertThat(resolver(mode = StorageMode.EXTERNAL).resolve()).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `external is unreadable until a provider is installed and granted`() {
|
||||
assertThat(resolver(mode = StorageMode.EXTERNAL).canReadStore()).isFalse()
|
||||
assertThat(
|
||||
resolver(installed = openTasksInstalled, mode = StorageMode.EXTERNAL).canReadStore(),
|
||||
).isFalse()
|
||||
assertThat(
|
||||
resolver(
|
||||
installed = openTasksInstalled,
|
||||
granted = openTasksGranted,
|
||||
mode = StorageMode.EXTERNAL,
|
||||
).canReadStore(),
|
||||
).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `external still requires the runtime permission`() {
|
||||
val resolver = resolver(installed = openTasksInstalled, mode = StorageMode.EXTERNAL)
|
||||
val provider = resolver.resolve()
|
||||
assertThat(provider).isNotNull()
|
||||
assertThat(resolver.hasPermission(provider!!)).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class ExternalCandidates {
|
||||
|
||||
@Test
|
||||
fun `prefer OpenTasks over tasks_org when both are installed`() {
|
||||
val resolver = resolver(
|
||||
installed = mapOf(
|
||||
"org.dmfs.tasks" to "org.dmfs.tasks",
|
||||
"org.tasks.opentasks" to "org.tasks",
|
||||
),
|
||||
mode = StorageMode.EXTERNAL,
|
||||
)
|
||||
assertThat(resolver.resolve()?.authority).isEqualTo("org.dmfs.tasks")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fall through to tasks_org when OpenTasks is absent`() {
|
||||
val resolver = resolver(
|
||||
installed = mapOf("org.tasks.opentasks" to "org.tasks"),
|
||||
mode = StorageMode.EXTERNAL,
|
||||
)
|
||||
assertThat(resolver.resolve()?.packageName).isEqualTo("org.tasks")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a mode change notifies listeners once, and only on a real change`() {
|
||||
// What store observers hang off: a live flow is bound to one store, so
|
||||
// it has to be told when the store underneath it is swapped.
|
||||
val resolver = resolver(mode = StorageMode.OWN)
|
||||
var fired = 0
|
||||
val handle = resolver.onModeChanged { fired++ }
|
||||
|
||||
resolver.storageMode = StorageMode.OWN
|
||||
assertThat(fired).isEqualTo(0)
|
||||
|
||||
resolver.storageMode = StorageMode.EXTERNAL
|
||||
assertThat(fired).isEqualTo(1)
|
||||
|
||||
handle.close()
|
||||
resolver.storageMode = StorageMode.OWN
|
||||
assertThat(fired).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `never name an authority of ours`() {
|
||||
// EXTERNAL must mean "somebody else's store", and Agendula publishes no
|
||||
// provider at all any more.
|
||||
assertThat(
|
||||
ProviderResolver.EXTERNAL_CANDIDATES.none {
|
||||
it.authority.startsWith("de.jeanlucmakiola")
|
||||
},
|
||||
).isTrue()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,6 @@ class TaskMapperTest {
|
||||
|
||||
val task = TaskMapper.task(reader)
|
||||
|
||||
assertThat(task.id).isEqualTo(42L)
|
||||
assertThat(task.taskId).isEqualTo(7L)
|
||||
assertThat(task.listId).isEqualTo(3L)
|
||||
assertThat(task.title).isEqualTo("Buy milk")
|
||||
@@ -50,6 +49,65 @@ class TaskMapperTest {
|
||||
assertThat(task.isSubtask).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an occurrence is identified by its recurrence-id anchor`() {
|
||||
fun occurrence(columns: Map<String, Any?>) =
|
||||
TaskMapper.task(MapColumnReader(columns + (Tasks.RRULE to "FREQ=DAILY")))
|
||||
|
||||
// instance_original_time is the provider's own RECURRENCE-ID and wins.
|
||||
val anchored = occurrence(
|
||||
mapOf(
|
||||
Instances.TASK_ID to 7L,
|
||||
Instances.INSTANCE_ORIGINAL_TIME to 500L,
|
||||
Instances.INSTANCE_START to 900L,
|
||||
),
|
||||
)
|
||||
assertThat(anchored.occurrenceStart?.toEpochMilliseconds()).isEqualTo(500L)
|
||||
assertThat(anchored.occurrenceKey).isEqualTo("7@500")
|
||||
|
||||
// Older provider schemas omit it; the occurrence's start reconstructs it.
|
||||
val byStart = occurrence(mapOf(Instances.TASK_ID to 7L, Instances.INSTANCE_START to 900L))
|
||||
assertThat(byStart.occurrenceStart?.toEpochMilliseconds()).isEqualTo(900L)
|
||||
|
||||
// A series carrying only DUE anchors on the due date instead.
|
||||
val byDue = occurrence(mapOf(Instances.TASK_ID to 7L, Instances.INSTANCE_DUE to 1_200L))
|
||||
assertThat(byDue.occurrenceStart?.toEpochMilliseconds()).isEqualTo(1_200L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a non-recurring task has no occurrence anchor and keys by task id`() {
|
||||
val task = TaskMapper.task(
|
||||
MapColumnReader(mapOf(Tasks.ID to 4L, Instances.INSTANCE_START to 500L)),
|
||||
)
|
||||
assertThat(task.occurrenceStart).isNull()
|
||||
assertThat(task.occurrenceKey).isEqualTo("4")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recurrence is detected from rrule when is_recurring is absent`() {
|
||||
// tasks.org's bundled provider is DB 22 and has no `is_recurring` column;
|
||||
// reading it alone would report the series as one-off and send its edits
|
||||
// to the master row, re-anchoring the whole thing.
|
||||
val task = TaskMapper.task(
|
||||
MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.RRULE to "FREQ=WEEKLY;BYDAY=MO")),
|
||||
)
|
||||
assertThat(task.isRecurring).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recurrence is detected from rdate alone`() {
|
||||
val task = TaskMapper.task(
|
||||
MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.RDATE to "20260720T090000Z")),
|
||||
)
|
||||
assertThat(task.isRecurring).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a plain task is not recurring`() {
|
||||
val task = TaskMapper.task(MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.TITLE to "One-off")))
|
||||
assertThat(task.isRecurring).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `falls back to instance id when task_id missing, and list color when no task color`() {
|
||||
val task = TaskMapper.task(
|
||||
|
||||
@@ -85,6 +85,57 @@ class TaskWriteMapperTest {
|
||||
assertThat(values[Tasks.TZ]).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all-day timestamps are pinned to UTC midnight`() {
|
||||
// 2026-07-20T22:00Z — i.e. local midnight on the 21st in Berlin (UTC+2).
|
||||
// The provider resolves all-day dates against UTC, so storing this as-is
|
||||
// would land the task on the 20th for anyone reading it back.
|
||||
val berlinMidnight = Instant.fromEpochMilliseconds(1_784_412_000_000L)
|
||||
val values = TaskWriteMapper.taskValues(
|
||||
TaskForm(title = "Holiday", listId = 1L, start = berlinMidnight, due = berlinMidnight, isAllDay = true),
|
||||
tzId = "Europe/Berlin",
|
||||
)
|
||||
|
||||
val dayMs = 24L * 60 * 60 * 1000
|
||||
assertThat(values[Tasks.DUE] as Long % dayMs).isEqualTo(0L)
|
||||
assertThat(values[Tasks.DTSTART] as Long % dayMs).isEqualTo(0L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `timed timestamps are written untouched`() {
|
||||
val at = Instant.fromEpochMilliseconds(1_784_412_345_678L)
|
||||
val values = TaskWriteMapper.taskValues(
|
||||
TaskForm(title = "Standup", listId = 1L, start = at, due = at),
|
||||
tzId = "Europe/Berlin",
|
||||
)
|
||||
assertThat(values[Tasks.DTSTART]).isEqualTo(1_784_412_345_678L)
|
||||
assertThat(values[Tasks.DUE]).isEqualTo(1_784_412_345_678L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duration is always cleared so it cannot collide with due`() {
|
||||
// The provider validates the *merged* row and throws "Only one of DUE or
|
||||
// DURATION must be supplied" if the stored row still carries a duration.
|
||||
val values = TaskWriteMapper.taskValues(
|
||||
TaskForm(title = "x", listId = 1L, due = Instant.fromEpochMilliseconds(5_000L)),
|
||||
tzId = "UTC",
|
||||
)
|
||||
assertThat(values.containsKey(Tasks.DURATION)).isTrue()
|
||||
assertThat(values[Tasks.DURATION]).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `instance values drop list and parent, which an override cannot express`() {
|
||||
val form = TaskForm(title = "x", listId = 4L, parentId = 7L, due = Instant.fromEpochMilliseconds(1_000L))
|
||||
val values = TaskWriteMapper.instanceValues(form, tzId = "UTC")
|
||||
|
||||
assertThat(values.containsKey(Tasks.LIST_ID)).isFalse()
|
||||
assertThat(values.containsKey(Tasks.PARENT_ID)).isFalse()
|
||||
// …but still carries the edit itself.
|
||||
assertThat(values[Tasks.TITLE]).isEqualTo("x")
|
||||
assertThat(values[Tasks.DUE]).isEqualTo(1_000L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `completion sets status, percent and timestamp, un-completion clears them`() {
|
||||
val done = TaskWriteMapper.completionValues(completed = true, nowMillis = 999L)
|
||||
@@ -97,6 +148,22 @@ class TaskWriteMapperTest {
|
||||
assertThat(undone[Tasks.COMPLETED]).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `alarm carries every column the provider's validator demands`() {
|
||||
val values = TaskWriteMapper.alarmValues(taskId = 12L, minutesBeforeDue = 30)
|
||||
|
||||
assertThat(values[TasksContract.Properties.TASK_ID]).isEqualTo(12L)
|
||||
assertThat(values[TasksContract.Properties.MIMETYPE])
|
||||
.isEqualTo("vnd.android.cursor.item/alarm")
|
||||
assertThat(values[TasksContract.Alarm.MINUTES_BEFORE]).isEqualTo(30)
|
||||
// REFERENCE must be present and non-negative, ALARM_TYPE present and
|
||||
// non-zero (0 is excluded from the provider's has_alarms count).
|
||||
assertThat(values[TasksContract.Alarm.REFERENCE]).isEqualTo(TasksContract.Alarm.REFERENCE_DUE)
|
||||
assertThat(values[TasksContract.Alarm.ALARM_TYPE]).isEqualTo(TasksContract.Alarm.TYPE_MESSAGE)
|
||||
// property_id must be absent or the insert is rejected.
|
||||
assertThat(values.containsKey(TasksContract.Properties.PROPERTY_ID)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `local list uses the LOCAL account`() {
|
||||
val values = TaskWriteMapper.localListValues("Inbox", 0x123)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user