Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9e29ad46c |
@@ -26,14 +26,14 @@ Which of these ran green, and anything you exercised by hand. On-device notes
|
||||
are especially useful for UI changes, and for anything touching the provider
|
||||
read/write paths (OpenTasks / tasks.org installed).
|
||||
|
||||
./gradlew lintFullDebug lintOfflineDebug :app:testFullDebugUnitTest :app:testOfflineDebugUnitTest :app:assembleDebug
|
||||
./gradlew lintDebug :app:testDebugUnitTest :app:assembleDebug
|
||||
python3 scripts/check_translations.py
|
||||
-->
|
||||
|
||||
|
||||
### Checklist
|
||||
|
||||
- [ ] `./gradlew lintFullDebug lintOfflineDebug :app:testFullDebugUnitTest :app:testOfflineDebugUnitTest :app:assembleDebug` passes locally
|
||||
- [ ] `./gradlew lintDebug :app:testDebugUnitTest :app:assembleDebug` passes locally
|
||||
- [ ] New domain logic comes with JVM unit tests under `app/src/test/`
|
||||
- [ ] Provider details stay inside `data/tasks/`
|
||||
- [ ] No `values-*/strings.xml` touched (Weblate owns those; new English strings in `values/` are fine)
|
||||
|
||||
@@ -37,29 +37,11 @@ jobs:
|
||||
- name: Reproducible-release invariant
|
||||
run: bash scripts/check_reproducible_release.sh
|
||||
|
||||
# The committed versionName must parse (X.Y.Z or X.Y.Z-beta.N) and the
|
||||
# committed versionCode must be the one it derives: the official F-Droid
|
||||
# repo builds the tag as committed. And a beta must never reach main,
|
||||
# where the release pipeline would ship it to F-Droid and Play; betas are
|
||||
# cut from release/* branches (docs/RELEASING.md).
|
||||
- name: Committed version is well-formed
|
||||
env:
|
||||
BASE: ${{ github.base_ref }}
|
||||
run: |
|
||||
set -e
|
||||
bash scripts/version_info.sh --check
|
||||
if [ "${BASE#refs/heads/}" = "main" ] && [ "$(bash scripts/version_info.sh channel)" = "beta" ]; then
|
||||
echo "ERROR: versionName $(bash scripts/version_info.sh version) is a beta." >&2
|
||||
echo "Set the stable version (and its versionCode) before merging into main." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Also cheap, also always-on. Two failures in one: the script exits
|
||||
# non-zero if this version's changelog is over the 500-character limit,
|
||||
# and the porcelain check below catches a version with no committed
|
||||
# changelog, which would otherwise ship the CHANGELOG.md section instead
|
||||
# of a hand-written summary. A beta version passes both: it ships no
|
||||
# What's New, so the script writes nothing for it.
|
||||
# of a hand-written summary.
|
||||
- name: Changelog fits the stores, and is committed
|
||||
run: |
|
||||
set -e
|
||||
@@ -127,12 +109,10 @@ jobs:
|
||||
|
||||
- name: Setup Java
|
||||
if: steps.scope.outputs.code == 'true'
|
||||
# JetBrains 21 is what gradle-daemon-jvm.properties asks for; installing it
|
||||
# here keeps Gradle from downloading it via foojay.
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'jetbrains'
|
||||
java-version: '21'
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
|
||||
# Fully qualified on purpose. Codeberg resolves bare `uses:` refs against
|
||||
# data.forgejo.org, Forgejo's own action mirror — actions/checkout,
|
||||
@@ -180,18 +160,18 @@ jobs:
|
||||
|
||||
# No --no-daemon: the daemon lives only as long as this job container
|
||||
# and lets the following steps skip JVM startup + reconfiguration.
|
||||
- name: Lint (debug variants only)
|
||||
- name: Lint (debug variant only)
|
||||
if: steps.scope.outputs.code == 'true'
|
||||
run: ./gradlew lintFullDebug lintOfflineDebug
|
||||
run: ./gradlew lintDebug
|
||||
|
||||
# :dav is a plain JVM module, so it has no testDebugUnitTest — naming only
|
||||
# that task would compile the vendored suite and run none of it, which is
|
||||
# the whole safety argument in dav/PROVENANCE.md.
|
||||
- name: Unit tests
|
||||
if: steps.scope.outputs.code == 'true'
|
||||
run: ./gradlew testFullDebugUnitTest testOfflineDebugUnitTest :dav:test :caldav:test
|
||||
run: ./gradlew testDebugUnitTest :dav:test :caldav:test
|
||||
|
||||
- name: Assemble debug APKs
|
||||
- name: Assemble debug APK
|
||||
if: steps.scope.outputs.code == 'true'
|
||||
run: ./gradlew assembleDebug
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Translations
|
||||
|
||||
# Fast, SDK-free parity check for translation resources, so Weblate PRs (which
|
||||
# only touch values-*/strings.xml) get quick feedback without the full Android
|
||||
# build. The deeper checks still run in CI via lintFullDebug (ExtraTranslation).
|
||||
# build. The deeper checks still run in CI via lintDebug (ExtraTranslation).
|
||||
#
|
||||
# Runs on every PR (no path filter) so the required "Translations / check"
|
||||
# status is always reported — like the `ci` job. A path-filtered workflow is
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
name: Beta — Codeberg pre-release
|
||||
|
||||
# A beta is cut by pushing a release branch whose committed versionName is
|
||||
# X.Y.Z-beta.N (see docs/RELEASING.md). Same model as release.yaml: the
|
||||
# committed version is the trigger and the vX.Y.Z-beta.N tag is an output. If
|
||||
# its Codeberg pre-release doesn't carry the APK yet, this runs the unit tests, builds and
|
||||
# signs the APK with the app key, records a Gitea pre-release (with the R8
|
||||
# mapping) and publishes a Codeberg pre-release with the APK + SHA-256.
|
||||
#
|
||||
# Deliberately nothing else. A beta never reaches the F-Droid repos or Play:
|
||||
# Obtainium hides pre-releases unless a user opts in, the official F-Droid
|
||||
# recipe only picks up `^v[0-9.]+$` tags, and Codeberg's "latest release"
|
||||
# skips pre-releases. Pushes of a release branch carrying a stable version
|
||||
# (the usual state) fall through `detect` and do nothing.
|
||||
#
|
||||
# Lives in .gitea/workflows next to release.yaml for the same reason: it needs
|
||||
# the app signing key, which only the self-hosted Gitea instance holds.
|
||||
on:
|
||||
push:
|
||||
branches: ['release/**']
|
||||
|
||||
concurrency:
|
||||
group: beta
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
detect:
|
||||
# Gitea only; see the same guard in release.yaml.
|
||||
if: github.repository_owner == 'makiolaj'
|
||||
runs-on: docker
|
||||
outputs:
|
||||
is_beta: ${{ steps.v.outputs.is_beta }}
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
version_code: ${{ steps.v.outputs.version_code }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve version and whether it still needs publishing
|
||||
id: v
|
||||
run: |
|
||||
set -e
|
||||
INFO=$(bash scripts/version_info.sh)
|
||||
echo "$INFO"
|
||||
echo "$INFO" >> "$GITHUB_OUTPUT"
|
||||
if [ "$(bash scripts/version_info.sh channel)" != beta ]; then
|
||||
echo "Not a beta — nothing to do."
|
||||
echo "is_beta=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# Done only once its Codeberg pre-release carries the APK, so a failed
|
||||
# publish is redone by the next push; refused unless newer than the
|
||||
# latest stable release.
|
||||
GATE=$(bash scripts/release_gate.sh)
|
||||
echo "is_beta=${GATE#cut=}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
beta:
|
||||
needs: detect
|
||||
if: needs.detect.outputs.is_beta == 'true'
|
||||
runs-on: docker
|
||||
env:
|
||||
ANDROID_HOME: /opt/android-sdk
|
||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||
VERSION: ${{ needs.detect.outputs.version }}
|
||||
VERSION_CODE: ${{ needs.detect.outputs.version_code }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup Java
|
||||
# JetBrains 21 is what gradle-daemon-jvm.properties asks for; installing it
|
||||
# here keeps Gradle from downloading it via foojay.
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'jetbrains'
|
||||
java-version: '21'
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
with:
|
||||
packages: ''
|
||||
|
||||
- name: Setup Android SDK cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /opt/android-sdk
|
||||
key: ${{ runner.os }}-android-sdk-37-36.0.0
|
||||
|
||||
- name: Install Android SDK packages
|
||||
run: |
|
||||
yes | sdkmanager --licenses >/dev/null || true
|
||||
sdkmanager \
|
||||
"platform-tools" \
|
||||
"platforms;android-37.0" \
|
||||
"build-tools;36.0.0"
|
||||
|
||||
- name: Setup Gradle cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- name: Install jq
|
||||
run: |
|
||||
set -e
|
||||
SUDO=""
|
||||
if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
$SUDO apt-get update
|
||||
$SUDO apt-get install -y jq
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
$SUDO apk add --no-cache jq
|
||||
fi
|
||||
|
||||
- name: Grant execute permission for gradlew
|
||||
run: chmod +x ./gradlew
|
||||
|
||||
- name: Pin versionCode to versionName
|
||||
run: |
|
||||
set -e
|
||||
sed -i "s/versionCode = .*/versionCode = $VERSION_CODE/" app/build.gradle.kts
|
||||
grep -E 'versionName|versionCode' app/build.gradle.kts
|
||||
|
||||
- name: Unit tests
|
||||
run: ./gradlew testFullDebugUnitTest
|
||||
|
||||
# The real app key, same as a stable release: a beta has to update in
|
||||
# place to the next beta and to the stable version.
|
||||
- name: Setup Android Keystore
|
||||
env:
|
||||
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
|
||||
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
||||
run: |
|
||||
mkdir -p app
|
||||
echo "$KEYSTORE_BASE64" | base64 --decode > app/upload-keystore.jks
|
||||
cat > key.properties <<EOF
|
||||
storePassword=$KEY_PASSWORD
|
||||
keyPassword=$KEY_PASSWORD
|
||||
keyAlias=$KEY_ALIAS
|
||||
storeFile=upload-keystore.jks
|
||||
EOF
|
||||
|
||||
# Both flavors; the offline one goes to Codeberg only (issue #39).
|
||||
- name: Build release APKs
|
||||
run: ./gradlew assembleRelease
|
||||
|
||||
# Notes = a `## [X.Y.Z-beta.N]` section if there is one, else
|
||||
# `## [Unreleased]`. Gitea creates the tag at this commit.
|
||||
- name: Create tag + Gitea pre-release
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -e
|
||||
bash scripts/release_notes.sh "$VERSION" > release-notes.md
|
||||
cat release-notes.md
|
||||
TAG="v$VERSION" PRERELEASE=true NOTES_FILE=release-notes.md \
|
||||
MAPPING=app/build/outputs/mapping/fullRelease/mapping.txt \
|
||||
MAPPING_OFFLINE=app/build/outputs/mapping/offlineRelease/mapping.txt \
|
||||
bash scripts/publish_gitea_release.sh
|
||||
|
||||
# The point of the whole workflow, so NOT continue-on-error.
|
||||
- name: Publish pre-release to Codeberg
|
||||
env:
|
||||
TOKEN: ${{ secrets.CODEBERG_RELEASE_TOKEN }}
|
||||
API: https://codeberg.org/api/v1/repos/jlmakiola/agendula
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -e
|
||||
TAG="v$VERSION" PRERELEASE=true NOTES_FILE=release-notes.md \
|
||||
APK=app/build/outputs/apk/full/release/app-full-release.apk \
|
||||
APK_OFFLINE=app/build/outputs/apk/offline/release/app-offline-release.apk \
|
||||
bash scripts/publish_codeberg_release.sh
|
||||
+220
-53
@@ -7,9 +7,7 @@ name: Release — F-Droid repo + Gitea/Codeberg release + Play
|
||||
# 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. Betas (X.Y.Z-beta.N) never come through here: beta.yaml cuts them
|
||||
# from release/* branches as Codeberg-only pre-releases, and `detect` refuses
|
||||
# one that reaches main.
|
||||
# nothing.
|
||||
#
|
||||
# A trailing `play` job then uploads the App Bundle to Google Play. It is last
|
||||
# and separate because Play can reject a good build for reasons the pipeline
|
||||
@@ -62,32 +60,60 @@ jobs:
|
||||
|
||||
- name: Resolve version and whether it is a new release
|
||||
id: v
|
||||
env:
|
||||
# Tags are read from Codeberg, which is canonical — deliberately NOT
|
||||
# from the Gitea API this workflow runs on. The Codeberg -> Gitea sync
|
||||
# is a push mirror, i.e. `git push --mirror`, which deletes refs the
|
||||
# source does not have. A tag minted here on Gitea is therefore wiped
|
||||
# by the next sync (Codeberg does not have it yet) and only reappears
|
||||
# once the tag push at the end of this workflow propagates back.
|
||||
# Asking Gitea inside that window would report "no tag" for a release
|
||||
# that already shipped, and cut it a second time.
|
||||
# Public repo, so this read needs no token.
|
||||
TAG_API: https://codeberg.org/api/v1/repos/jlmakiola/agendula
|
||||
run: |
|
||||
set -e
|
||||
# versionName -> versionCode, channel and the pre-release flag (set
|
||||
# while MAJOR was 0) all come from the one script beta.yaml uses too.
|
||||
INFO=$(bash scripts/version_info.sh)
|
||||
echo "$INFO"
|
||||
echo "$INFO" >> "$GITHUB_OUTPUT"
|
||||
VERSION=$(echo "$INFO" | sed -n 's/^version=//p')
|
||||
CHANNEL=$(echo "$INFO" | sed -n 's/^channel=//p')
|
||||
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
|
||||
if [ -z "$VERSION" ]; then echo "No versionName in app/build.gradle.kts" >&2; exit 1; fi
|
||||
MAJOR=$(echo "$VERSION" | cut -d. -f1); MINOR=$(echo "$VERSION" | cut -d. -f2); PATCH=$(echo "$VERSION" | cut -d. -f3)
|
||||
MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0}
|
||||
VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "version_code=$VERSION_CODE" >> "$GITHUB_OUTPUT"
|
||||
# Pre-1.0 is not stable yet: mark the Gitea release as a pre-release
|
||||
# while MAJOR is 0. Graduates to a stable release automatically at 1.0.0.
|
||||
if [ "$MAJOR" = "0" ]; then PRERELEASE=true; else PRERELEASE=false; fi
|
||||
echo "prerelease=$PRERELEASE" >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved version $VERSION (code $VERSION_CODE, prerelease=$PRERELEASE)"
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "Manual dispatch — re-sign path, not a release."
|
||||
echo "is_release=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# Betas ship from release/* branches via beta.yaml and must never be
|
||||
# cut here: this path publishes to F-Droid and Play. CI blocks such a
|
||||
# PR into main; this is the backstop if one gets through anyway.
|
||||
if [ "$CHANNEL" != "stable" ]; then
|
||||
echo "versionName $VERSION on main is a beta. Set the stable version before merging to main." >&2
|
||||
exit 1
|
||||
fi
|
||||
# Tags are read from Codeberg, the canonical forge, by exact name: its
|
||||
# git/refs/tags API matches by prefix, so v1.1.0-beta.1 would read as
|
||||
# v1.1.0. A lookup error is fatal rather than read as "no tag".
|
||||
GATE=$(bash scripts/release_gate.sh)
|
||||
echo "is_release=${GATE#cut=}" >> "$GITHUB_OUTPUT"
|
||||
# A tag for this version already existing means the release shipped on
|
||||
# an earlier push; do nothing. Absent => this merge cuts the release.
|
||||
#
|
||||
# Anything other than a clean 200/404 is treated as fatal rather than
|
||||
# as "no tag". A Codeberg outage or a network blip would otherwise
|
||||
# read as absent and re-cut a release that has already shipped —
|
||||
# republishing to F-Droid. Failing here is recoverable; a duplicate
|
||||
# release is not.
|
||||
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$TAG_API/git/refs/tags/v$VERSION" || echo 000)
|
||||
case "$STATUS" in
|
||||
200)
|
||||
echo "Tag v$VERSION already exists on Codeberg — nothing to release."
|
||||
echo "is_release=false" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
404)
|
||||
echo "No tag for v$VERSION on Codeberg yet — cutting the release."
|
||||
echo "is_release=true" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
*)
|
||||
echo "Codeberg tag lookup for v$VERSION returned HTTP $STATUS." >&2
|
||||
echo "Refusing to guess: treating this as 'no tag' could re-cut a shipped release." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Before a single Gradle task runs: F-Droid truncates the in-client
|
||||
# changelog, so an over-long one would reach users cut off mid-sentence.
|
||||
@@ -120,12 +146,10 @@ jobs:
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup Java
|
||||
# JetBrains 21 is what gradle-daemon-jvm.properties asks for; installing it
|
||||
# here keeps Gradle from downloading it via foojay.
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'jetbrains'
|
||||
java-version: '21'
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
@@ -172,8 +196,8 @@ jobs:
|
||||
run: chmod +x ./gradlew
|
||||
|
||||
# The committed versionName is the source of truth. Pin versionCode to the
|
||||
# value scripts/version_info.sh derives from it, so the published APK's
|
||||
# code follows the scheme even if the committed code was forgotten.
|
||||
# value derived from it so the published APK's code is always
|
||||
# MAJOR*10000 + MINOR*100 + PATCH even if the committed code was forgotten.
|
||||
- name: Pin versionCode to versionName
|
||||
if: env.IS_RELEASE == 'true'
|
||||
run: |
|
||||
@@ -184,7 +208,7 @@ jobs:
|
||||
# Test the exact commit being shipped (only on a real release).
|
||||
- name: Unit tests
|
||||
if: env.IS_RELEASE == 'true'
|
||||
run: ./gradlew testFullDebugUnitTest
|
||||
run: ./gradlew testDebugUnitTest
|
||||
|
||||
- name: Setup Android Keystore
|
||||
if: env.IS_RELEASE == 'true'
|
||||
@@ -202,9 +226,7 @@ jobs:
|
||||
storeFile=upload-keystore.jks
|
||||
EOF
|
||||
|
||||
# Both flavors. Only full goes to F-Droid and Play; the offline one
|
||||
# (issue #39) is a Codeberg release asset and nothing else.
|
||||
- name: Build release APKs
|
||||
- name: Build release APK
|
||||
if: env.IS_RELEASE == 'true'
|
||||
run: ./gradlew assembleRelease
|
||||
|
||||
@@ -260,7 +282,7 @@ jobs:
|
||||
run: |
|
||||
set -e
|
||||
mkdir -p fdroid/repo
|
||||
cp app/build/outputs/apk/full/release/app-full-release.apk "fdroid/repo/agendula_v${VERSION}.apk"
|
||||
cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/agendula_v${VERSION}.apk"
|
||||
|
||||
# Per-version "What's New": ensure this version's changelog exists in the
|
||||
# fastlane tree (committed at release-cut time for the official repo; this
|
||||
@@ -310,8 +332,6 @@ jobs:
|
||||
# Creating it with target_commitish makes Gitea create the vX.Y.Z tag at
|
||||
# this commit, so the tag only ever marks a fully-shipped release (and a
|
||||
# failure before here leaves no tag, so re-running the workflow retries).
|
||||
# Also attaches the R8 mapping (best-effort) so user crash stacktraces
|
||||
# stay deobfuscatable. Notes = this version's CHANGELOG section.
|
||||
- name: Create tag + Gitea release
|
||||
if: env.IS_RELEASE == 'true'
|
||||
env:
|
||||
@@ -320,18 +340,85 @@ jobs:
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -e
|
||||
bash scripts/release_notes.sh "$VERSION" > release-notes.md
|
||||
TAG="v$VERSION" NOTES_FILE=release-notes.md \
|
||||
MAPPING=app/build/outputs/mapping/fullRelease/mapping.txt \
|
||||
MAPPING_OFFLINE=app/build/outputs/mapping/offlineRelease/mapping.txt \
|
||||
bash scripts/publish_gitea_release.sh
|
||||
TAG="v$VERSION"
|
||||
# Notes = this version's CHANGELOG section.
|
||||
awk -v ver="$VERSION" '
|
||||
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
|
||||
/^## \[/ { flag = 0 }
|
||||
flag' CHANGELOG.md > release-notes.md
|
||||
sed -i -e '/./,$!d' release-notes.md
|
||||
if [ ! -s release-notes.md ]; then
|
||||
echo "_No changelog entry for ${VERSION} — see CHANGELOG.md._" > release-notes.md
|
||||
fi
|
||||
python3 - "$TAG" "$SHA" "$PRERELEASE" <<'PY' > payload.json
|
||||
import json, sys
|
||||
print(json.dumps({
|
||||
"tag_name": sys.argv[1],
|
||||
"target_commitish": sys.argv[2],
|
||||
"name": sys.argv[1],
|
||||
"body": open("release-notes.md").read(),
|
||||
"draft": False,
|
||||
# Pre-1.0 releases are flagged as pre-releases (see detect job).
|
||||
"prerelease": sys.argv[3] == "true",
|
||||
}))
|
||||
PY
|
||||
# Upsert (re-run safe): PATCH if a release for the tag already exists,
|
||||
# else POST a new one (which also creates the tag at target_commitish).
|
||||
curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" > existing.json
|
||||
ID=$(jq -r '.id // empty' existing.json 2>/dev/null || true)
|
||||
if [ -n "$ID" ]; then
|
||||
CODE=$(curl -s -o response.json -w '%{http_code}' -X PATCH \
|
||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||
-d @payload.json "$API/releases/$ID")
|
||||
OK=200
|
||||
else
|
||||
CODE=$(curl -s -o response.json -w '%{http_code}' -X POST \
|
||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||
-d @payload.json "$API/releases")
|
||||
OK=201
|
||||
fi
|
||||
cat response.json
|
||||
if [ "$CODE" != "$OK" ]; then
|
||||
echo "Release upsert failed with HTTP $CODE (expected $OK)" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Created/updated release $TAG at $SHA"
|
||||
|
||||
# Archive the R8 mapping so user crash stacktraces stay deobfuscatable.
|
||||
# Attached to the release (it's not an APK, so it fits the no-binaries
|
||||
# rule). Best-effort: never fail a release over it.
|
||||
- name: Attach R8 mapping to Gitea release
|
||||
if: env.IS_RELEASE == 'true'
|
||||
continue-on-error: true
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
run: |
|
||||
set -e
|
||||
MAP="app/build/outputs/mapping/release/mapping.txt"
|
||||
if [ ! -f "$MAP" ]; then echo "No mapping.txt (R8 off?) — skipping."; exit 0; fi
|
||||
TAG="v$VERSION"
|
||||
ASSET="mapping-${VERSION}.txt.gz"
|
||||
gzip -c "$MAP" > "/tmp/$ASSET"
|
||||
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
|
||||
if [ -z "$ID" ]; then echo "Could not resolve release id — skipping."; exit 0; fi
|
||||
# Replace any prior asset of the same name (re-run safe).
|
||||
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
|
||||
| jq -r --arg n "$ASSET" '.[] | select(.name==$n) | .id')
|
||||
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
|
||||
curl -s -X POST -H "Authorization: token $TOKEN" \
|
||||
-F "attachment=@/tmp/$ASSET" \
|
||||
"$API/releases/$ID/assets?name=$ASSET" -o /dev/null -w "asset upload HTTP %{http_code}\n"
|
||||
|
||||
# Publish the release on Codeberg, which is canonical for tags and
|
||||
# releases (see docs/RELEASING.md): push the tag there and attach 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.
|
||||
# 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'
|
||||
# NOT continue-on-error: this step reported green through 0.2.1, 0.2.2,
|
||||
@@ -344,11 +431,91 @@ jobs:
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -e
|
||||
[ -s release-notes.md ] || bash scripts/release_notes.sh "$VERSION" > release-notes.md
|
||||
TAG="v$VERSION" NOTES_FILE=release-notes.md \
|
||||
APK=app/build/outputs/apk/full/release/app-full-release.apk \
|
||||
APK_OFFLINE=app/build/outputs/apk/offline/release/app-offline-release.apk \
|
||||
bash scripts/publish_codeberg_release.sh
|
||||
if [ -z "${TOKEN:-}" ]; then
|
||||
echo "CODEBERG_RELEASE_TOKEN not set — skipping Codeberg publish."
|
||||
exit 0
|
||||
fi
|
||||
TAG="v$VERSION"
|
||||
APK="app/build/outputs/apk/release/app-release.apk"
|
||||
if [ ! -f "$APK" ]; then echo "No release APK found — skipping." >&2; exit 1; fi
|
||||
ASSET_APK="agendula_v${VERSION}.apk"
|
||||
ASSET_SUM="${ASSET_APK}.sha256"
|
||||
cp "$APK" "/tmp/$ASSET_APK"
|
||||
( cd /tmp && sha256sum "$ASSET_APK" > "$ASSET_SUM" )
|
||||
|
||||
# Release notes: reuse the section extracted for the Gitea release,
|
||||
# fall back to the CHANGELOG entry if that step's file is gone.
|
||||
if [ ! -s release-notes.md ]; then
|
||||
awk -v ver="$VERSION" '
|
||||
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
|
||||
/^## \[/ { flag = 0 }
|
||||
flag' CHANGELOG.md > release-notes.md
|
||||
sed -i -e '/./,$!d' release-notes.md
|
||||
fi
|
||||
[ -s release-notes.md ] || echo "_See CHANGELOG.md for ${VERSION}._" > release-notes.md
|
||||
# Push the tag to Codeberg ourselves. Under Codeberg-canonical the
|
||||
# mirror runs Codeberg -> Gitea, so waiting for a tag to arrive here
|
||||
# from Gitea (what 0.3.2 did) would wait forever. The tag this
|
||||
# pipeline minted on Gitea is in fact *deleted* by the next mirror
|
||||
# sync until Codeberg has it — so pushing it here is what makes it
|
||||
# durable on both forges.
|
||||
#
|
||||
# Pushing the ref first and attaching with NO target_commitish is
|
||||
# deliberate: a release POST carrying a target_commitish for a commit
|
||||
# or tag Codeberg hasn't received yet is what produced the
|
||||
# empty-bodied 500s. Attaching to a ref that already exists doesn't
|
||||
# need the API to write one.
|
||||
git tag -f "$TAG" "$SHA"
|
||||
git push -f "https://jlmakiola:${TOKEN}@codeberg.org/jlmakiola/agendula.git" \
|
||||
"refs/tags/$TAG"
|
||||
python3 - "$TAG" "$PRERELEASE" <<'PY' > cb-payload.json
|
||||
import json, sys
|
||||
tag, pre = sys.argv[1:3]
|
||||
print(json.dumps({
|
||||
"tag_name": tag,
|
||||
"name": tag,
|
||||
"body": open("release-notes.md").read(),
|
||||
"draft": False,
|
||||
# Pre-1.0 releases are flagged as pre-releases (see detect job).
|
||||
"prerelease": pre == "true",
|
||||
}))
|
||||
PY
|
||||
# Create (or update) the release. Codeberg 500s on a POST/GET against a
|
||||
# tag it has only just received — the release request outruns the
|
||||
# indexing of the ref we pushed a moment ago — so a single attempt can
|
||||
# fail even though the very same call succeeds seconds later. Retry
|
||||
# with backoff, and PATCH in place if a release already exists (re-run
|
||||
# safe, so re-running never disturbs a published release). A 5xx body
|
||||
# still exits curl 0, so the loop, not `set -e`, controls the flow.
|
||||
ID=""
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
EXIST=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty' 2>/dev/null || true)
|
||||
if [ -n "$EXIST" ]; then
|
||||
curl -s -o /dev/null -w "release PATCH HTTP %{http_code}\n" -X PATCH \
|
||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||
-d @cb-payload.json "$API/releases/$EXIST"
|
||||
ID="$EXIST"; break
|
||||
fi
|
||||
CODE=$(curl -s -o cb-response.json -w "%{http_code}" -X POST \
|
||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||
-d @cb-payload.json "$API/releases")
|
||||
echo "release POST attempt $attempt HTTP $CODE"
|
||||
ID=$(jq -r '.id // empty' cb-response.json 2>/dev/null || true)
|
||||
[ -n "$ID" ] && break
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
if [ -z "$ID" ]; then echo "Could not resolve Codeberg release id after retries." >&2; exit 1; fi
|
||||
|
||||
# Attach APK + checksum, replacing any prior asset of the same name.
|
||||
for A in "$ASSET_APK" "$ASSET_SUM"; do
|
||||
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
|
||||
| jq -r --arg n "$A" '.[] | select(.name==$n) | .id')
|
||||
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
|
||||
curl -s -X POST -H "Authorization: token $TOKEN" \
|
||||
-F "attachment=@/tmp/$A" \
|
||||
"$API/releases/$ID/assets?name=$A" -o /dev/null -w "asset $A HTTP %{http_code}\n"
|
||||
done
|
||||
echo "Published $TAG to Codeberg."
|
||||
|
||||
# Play takes an App Bundle, not the APK: a second artifact from the same
|
||||
# source and signing config. Play treats the release key only as the
|
||||
@@ -362,7 +529,7 @@ jobs:
|
||||
- name: Build release AAB
|
||||
if: env.IS_RELEASE == 'true'
|
||||
continue-on-error: true
|
||||
run: ./gradlew bundleFullRelease
|
||||
run: ./gradlew bundleRelease
|
||||
|
||||
# NOT actions/upload-artifact@v4: its client refuses any non-github.com
|
||||
# server as unsupported GHES (go-gitea/gitea#36024). This fork drops that
|
||||
@@ -374,7 +541,7 @@ jobs:
|
||||
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 # v4
|
||||
with:
|
||||
name: release-aab-${{ needs.detect.outputs.version }}
|
||||
path: app/build/outputs/bundle/fullRelease/app-full-release.aab
|
||||
path: app/build/outputs/bundle/release/app-release.aab
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
@@ -468,7 +635,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Absolute: a lane body runs from fastlane/, not the workspace root.
|
||||
AAB="$GITHUB_WORKSPACE/dist/app-full-release.aab"
|
||||
AAB="$GITHUB_WORKSPACE/dist/app-release.aab"
|
||||
test -f "$AAB" || { echo "No AAB at $AAB — the artifact handoff failed." >&2; ls -la dist || true; exit 1; }
|
||||
bundle exec fastlane deploy \
|
||||
aab:"$AAB" \
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
name: Renovate
|
||||
|
||||
on:
|
||||
# Every merge to main. Mirror syncs from Codeberg fire push events here (the
|
||||
# same trigger release.yaml relies on), so a merged Renovate PR is followed
|
||||
# within minutes by a run that rebases the sibling PRs it just conflicted —
|
||||
# most bumps touch gradle/libs.versions.toml. Outside renovate.json5's
|
||||
# `schedule` window such a run only maintains existing branches
|
||||
# (updateNotScheduled), it never opens new PRs. Renovate's own rebases push
|
||||
# to renovate/* branches, not main, so this cannot loop.
|
||||
push:
|
||||
branches: [main]
|
||||
# Weekly sweep for new updates. Mondays 05:00 UTC, inside the schedule window
|
||||
# in renovate.json5 — keep the two in step.
|
||||
# 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.
|
||||
|
||||
+2
-26
@@ -7,34 +7,10 @@ All notable changes to this project are documented here. The format follows
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Four new home-screen widgets: Today, Up next, Lists and Week.
|
||||
- A widget can show tasks from several lists at once, optionally grouped by
|
||||
list.
|
||||
- A setting under Settings → Task form pre-fills a new task's start with today.
|
||||
- An offline version of Agendula with no sync and no network access at all,
|
||||
published as a separate APK on Codeberg (#39).
|
||||
- Tags. Add them while editing a task, see them on the task and in lists,
|
||||
browse them from the home screen and filter any list by them. They are saved
|
||||
as `CATEGORIES`, so they sync with Nextcloud Tasks, tasks.org and DAVx⁵ (#35).
|
||||
## [1.0.1] - 2026-09-25
|
||||
|
||||
### Changed
|
||||
- The Tasks widget has agenda-style rows, an optional row colour and an "All
|
||||
caught up" state. Ticking a task in any widget shows it as done for a moment
|
||||
before it disappears.
|
||||
- The "At a glance" widget is gone; the Tasks and Today widgets cover what it
|
||||
showed.
|
||||
- Synced lists can be renamed and deleted, not only device-only ones. A
|
||||
read-only share shows the edit button greyed out and explains why on tap.
|
||||
- The sort button in task lists now opens "Sort and filter", which can also
|
||||
filter by tag.
|
||||
- While editing a task, its title moves into the top bar once you scroll past
|
||||
it.
|
||||
|
||||
### Fixed
|
||||
- The list editor's Where and smart-lists rows no longer have double padding.
|
||||
- A field you tap now scrolls fully above the keyboard, and the "add subtask"
|
||||
row in task lists no longer ends up hidden behind it.
|
||||
- First release on Google Play. No changes to the app since 1.0.0.
|
||||
|
||||
## [1.0.0] - 2026-09-21
|
||||
|
||||
|
||||
+3
-3
@@ -28,9 +28,9 @@ layer.
|
||||
## Build, test, lint
|
||||
|
||||
```sh
|
||||
./gradlew :app:assembleDebug # build the debug APKs (full + offline)
|
||||
./gradlew :app:testFullDebugUnitTest # JVM unit tests (JUnit5 + Truth + Turbine)
|
||||
./gradlew lintFullDebug lintOfflineDebug # Android lint (CI runs this on every PR)
|
||||
./gradlew :app:assembleDebug # build the debug APK
|
||||
./gradlew :app:testDebugUnitTest # JVM unit tests (JUnit5 + Truth + Turbine)
|
||||
./gradlew lintDebug # Android lint (CI runs this on every PR)
|
||||
```
|
||||
|
||||
CI (`.forgejo/workflows/ci.yaml`, on Codeberg) runs a reproducible-release invariant check,
|
||||
|
||||
@@ -20,14 +20,6 @@ required.</p>
|
||||
<a href="https://ko-fi.com/jeanlucmakiola"><img src="https://storage.ko-fi.com/cdn/brandasset/v2/support_me_on_kofi_badge_beige.png" alt="Support me on Ko-fi" height="56"></a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01.png" alt="Home screen: today's progress, overdue and upcoming tasks, and your lists" width="18%">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02.png" alt="Onboarding: sync with your own CalDAV server" width="18%">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03.png" alt="Upcoming list with a task expanded into its subtasks" width="18%">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04.png" alt="Task detail: a monthly recurring task with a reminder and priority" width="18%">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05.png" alt="Reminder notification with Done and Snooze actions" width="18%">
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
Agendula is the task-list sibling to [Calendula](https://codeberg.org/jlmakiola/calendula).
|
||||
@@ -119,20 +111,6 @@ APK and a `.sha256` checksum attached — the same APK the F-Droid repository
|
||||
serves. For automatic updates from there, use
|
||||
**[Obtainium](https://github.com/ImranR98/Obtainium)** and
|
||||
**[add Agendula in one tap](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/agendula)**.
|
||||
Each release carries two APKs (see [Offline version](#offline-version)), so set
|
||||
*Filter APKs by regular expression* to `^agendula_v` to always get the regular
|
||||
app.
|
||||
Betas of upcoming versions are published there too, as pre-releases; to test
|
||||
them, switch on *Include prereleases* for Agendula in Obtainium.
|
||||
|
||||
### Offline version
|
||||
|
||||
Each Codeberg release also carries `agendula-offline_v<version>.apk`: Agendula
|
||||
without CalDAV sync and **without the network permission**, so it cannot
|
||||
connect to anything. It installs alongside the regular app rather than replacing
|
||||
it; move tasks between the two with Settings → Storage → Export / Import. In
|
||||
Obtainium, pick it with *Filter APKs by regular expression* set to
|
||||
`^agendula-offline_v`.
|
||||
|
||||
### Build from source
|
||||
|
||||
|
||||
+11
-60
@@ -1,4 +1,3 @@
|
||||
import com.android.build.api.artifact.SingleArtifact
|
||||
import java.util.Properties
|
||||
import java.io.FileInputStream
|
||||
|
||||
@@ -27,14 +26,12 @@ android {
|
||||
// These committed values ARE the source of truth for a release: merging
|
||||
// a bumped versionName into main triggers .gitea/workflows/release.yaml,
|
||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||
// release itself. A versionName of X.Y.Z-beta.N pushed to a release/*
|
||||
// branch instead cuts a Codeberg-only pre-release (beta.yaml).
|
||||
// versionCode is derived from versionName by scripts/version_info.sh
|
||||
// (1.0.x: 1.0.0 -> 10000; from 1.1.0: 1.1.0-beta.1 -> 1010001,
|
||||
// 1.1.0 -> 1010099), and CI fails if the committed one doesn't match.
|
||||
// See docs/RELEASING.md.
|
||||
versionCode = 1010003
|
||||
versionName = "1.1.0-beta.3"
|
||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||
// PATCH from versionName, e.g. 1.0.0 -> 10000). Releases were flagged as
|
||||
// pre-releases while MAJOR was 0; 1.0.0 is the first stable one, and the
|
||||
// pipeline graduates it on its own. See docs/RELEASING.md.
|
||||
versionCode = 10001
|
||||
versionName = "1.0.1"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
@@ -47,20 +44,6 @@ android {
|
||||
resValue("string", "sync_authority", "de.jeanlucmakiola.agendula.sync")
|
||||
}
|
||||
|
||||
// `offline` (#39): no sync code, network libraries or network permission.
|
||||
flavorDimensions += "network"
|
||||
productFlavors {
|
||||
create("full") {
|
||||
dimension = "network"
|
||||
buildConfigField("boolean", "SYNC_ENABLED", "true")
|
||||
}
|
||||
create("offline") {
|
||||
dimension = "network"
|
||||
applicationIdSuffix = ".offline"
|
||||
buildConfigField("boolean", "SYNC_ENABLED", "false")
|
||||
}
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
create("release") {
|
||||
@@ -223,26 +206,26 @@ dependencies {
|
||||
// @HiltWorker plumbing.
|
||||
implementation(libs.androidx.work.runtime.ktx)
|
||||
// Custom Tabs: the Nextcloud login flow hands the browser an approval page.
|
||||
"fullImplementation"(libs.androidx.browser)
|
||||
implementation(libs.androidx.browser)
|
||||
implementation(libs.androidx.hilt.work)
|
||||
ksp(libs.androidx.hilt.compiler)
|
||||
|
||||
// Push sync: a UnifiedPush distributor delivers the server's WebDAV-Push messages.
|
||||
"fullImplementation"(libs.unifiedpush.connector)
|
||||
implementation(libs.unifiedpush.connector)
|
||||
|
||||
// RFC 5545 recurrence expansion, in-process; see the catalog for the pin.
|
||||
implementation(libs.dmfs.lib.recur)
|
||||
|
||||
// Vendored dav4jvm — the CalDAV protocol layer. See dav/PROVENANCE.md.
|
||||
"fullImplementation"(project(":dav"))
|
||||
implementation(project(":dav"))
|
||||
// Discovery, auth and Nextcloud Login Flow v2.
|
||||
"fullImplementation"(project(":caldav"))
|
||||
implementation(project(":caldav"))
|
||||
// :dav gets org.xmlpull.v1 from the Android framework at runtime and declares
|
||||
// xpp3 compileOnly, which is not transitive. Unit tests run on a plain JVM
|
||||
// with no framework, and android.jar's stub factory returns null under
|
||||
// isReturnDefaultValues — so anything touching XmlUtils would NPE without a
|
||||
// real implementation here.
|
||||
"testFullImplementation"(libs.xpp3)
|
||||
testImplementation(libs.xpp3)
|
||||
|
||||
implementation(libs.androidx.room.runtime)
|
||||
implementation(libs.androidx.room.ktx)
|
||||
@@ -261,7 +244,6 @@ dependencies {
|
||||
implementation(libs.floret.core.crash)
|
||||
implementation(libs.floret.identity)
|
||||
implementation(libs.floret.components)
|
||||
implementation(libs.floret.glance)
|
||||
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
debugImplementation(libs.androidx.ui.test.manifest)
|
||||
@@ -281,34 +263,3 @@ dependencies {
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.ui.test.junit4)
|
||||
}
|
||||
|
||||
/** Fails the build if the offline flavor's merged manifest asks for the network. */
|
||||
abstract class VerifyNoNetworkPermissions : DefaultTask() {
|
||||
@get:InputFile
|
||||
@get:PathSensitive(PathSensitivity.NONE)
|
||||
abstract val manifest: RegularFileProperty
|
||||
|
||||
@TaskAction
|
||||
fun verify() {
|
||||
val text = manifest.get().asFile.readText()
|
||||
val found = listOf("INTERNET", "ACCESS_NETWORK_STATE", "ACCESS_WIFI_STATE", "CHANGE_NETWORK_STATE")
|
||||
.filter { "\"android.permission.$it\"" in text }
|
||||
if (found.isNotEmpty()) {
|
||||
throw GradleException(
|
||||
"The offline flavor's merged manifest declares ${found.joinToString()}. " +
|
||||
"Remove it in app/src/offline/AndroidManifest.xml with tools:node=\"remove\".",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
androidComponents {
|
||||
onVariants(selector().withFlavor("network" to "offline")) { variant ->
|
||||
val name = variant.name.replaceFirstChar { it.uppercase() }
|
||||
val verify = tasks.register<VerifyNoNetworkPermissions>("verify${name}Manifest") {
|
||||
manifest.set(variant.artifacts.get(SingleArtifact.MERGED_MANIFEST))
|
||||
}
|
||||
tasks.matching { it.name in setOf("package$name", "package${name}Bundle", "check") }
|
||||
.configureEach { dependsOn(verify) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,529 +0,0 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 2,
|
||||
"identityHash": "14b159c51416ba27653f6320ac7f6f03",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "accounts",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `display_name` TEXT NOT NULL, `principal_url` TEXT, `home_set_url` TEXT, `username` TEXT, `last_sync_at` INTEGER, `last_sync_error` TEXT)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "display_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "principalUrl",
|
||||
"columnName": "principal_url",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "homeSetUrl",
|
||||
"columnName": "home_set_url",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "username",
|
||||
"columnName": "username",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastSyncAt",
|
||||
"columnName": "last_sync_at",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastSyncError",
|
||||
"columnName": "last_sync_error",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "task_lists",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `account_id` INTEGER, `is_visible` INTEGER NOT NULL DEFAULT 1, `is_synced` INTEGER NOT NULL DEFAULT 1, `owner` TEXT, `is_read_only` INTEGER NOT NULL DEFAULT 0, `sort_order` INTEGER NOT NULL DEFAULT 0, `href` TEXT, `ctag` TEXT, `sync_token` TEXT, `is_dirty` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`account_id`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "color",
|
||||
"columnName": "color",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "account_id",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isVisible",
|
||||
"columnName": "is_visible",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "1"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isSynced",
|
||||
"columnName": "is_synced",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "1"
|
||||
},
|
||||
{
|
||||
"fieldPath": "owner",
|
||||
"columnName": "owner",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isReadOnly",
|
||||
"columnName": "is_read_only",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "sortOrder",
|
||||
"columnName": "sort_order",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "href",
|
||||
"columnName": "href",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "ctag",
|
||||
"columnName": "ctag",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "syncToken",
|
||||
"columnName": "sync_token",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isDirty",
|
||||
"columnName": "is_dirty",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_task_lists_account_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"account_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_task_lists_account_id` ON `${TABLE_NAME}` (`account_id`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "accounts",
|
||||
"onDelete": "SET NULL",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"account_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "tasks",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `list_id` INTEGER NOT NULL, `uid` TEXT NOT NULL, `href` TEXT, `etag` TEXT, `title` TEXT, `description` TEXT, `location` TEXT, `url` TEXT, `color` INTEGER, `categories` TEXT NOT NULL DEFAULT '', `status` INTEGER NOT NULL DEFAULT 0, `percent_complete` INTEGER, `completed_at` INTEGER, `priority` INTEGER NOT NULL DEFAULT 0, `classification` INTEGER, `dtstart` INTEGER, `due` INTEGER, `duration` TEXT, `is_all_day` INTEGER NOT NULL DEFAULT 0, `timezone` TEXT, `rrule` TEXT, `rdate` TEXT, `exdate` TEXT, `recurrence_id` INTEGER, `master_id` INTEGER, `parent_id` INTEGER, `sort_order` INTEGER NOT NULL DEFAULT 0, `created_at` INTEGER, `last_modified` INTEGER, `sequence` INTEGER NOT NULL DEFAULT 0, `is_dirty` INTEGER NOT NULL DEFAULT 0, `is_deleted` INTEGER NOT NULL DEFAULT 0, `unknown_properties` TEXT, FOREIGN KEY(`list_id`) REFERENCES `task_lists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`master_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parent_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "listId",
|
||||
"columnName": "list_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "uid",
|
||||
"columnName": "uid",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "href",
|
||||
"columnName": "href",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "etag",
|
||||
"columnName": "etag",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "description",
|
||||
"columnName": "description",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "location",
|
||||
"columnName": "location",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "color",
|
||||
"columnName": "color",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "categories",
|
||||
"columnName": "categories",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "status",
|
||||
"columnName": "status",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "percentComplete",
|
||||
"columnName": "percent_complete",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "completedAt",
|
||||
"columnName": "completed_at",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "priority",
|
||||
"columnName": "priority",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "classification",
|
||||
"columnName": "classification",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "dtstart",
|
||||
"columnName": "dtstart",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "due",
|
||||
"columnName": "due",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "duration",
|
||||
"columnName": "duration",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isAllDay",
|
||||
"columnName": "is_all_day",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "timezone",
|
||||
"columnName": "timezone",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "rrule",
|
||||
"columnName": "rrule",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "rdate",
|
||||
"columnName": "rdate",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "exdate",
|
||||
"columnName": "exdate",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "recurrenceId",
|
||||
"columnName": "recurrence_id",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "masterId",
|
||||
"columnName": "master_id",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "parentId",
|
||||
"columnName": "parent_id",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "sortOrder",
|
||||
"columnName": "sort_order",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "created_at",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastModified",
|
||||
"columnName": "last_modified",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "sequence",
|
||||
"columnName": "sequence",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isDirty",
|
||||
"columnName": "is_dirty",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isDeleted",
|
||||
"columnName": "is_deleted",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "unknownProperties",
|
||||
"columnName": "unknown_properties",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_tasks_list_id_is_deleted",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"list_id",
|
||||
"is_deleted"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_list_id_is_deleted` ON `${TABLE_NAME}` (`list_id`, `is_deleted`)"
|
||||
},
|
||||
{
|
||||
"name": "index_tasks_parent_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"parent_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_parent_id` ON `${TABLE_NAME}` (`parent_id`)"
|
||||
},
|
||||
{
|
||||
"name": "index_tasks_master_id_recurrence_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"master_id",
|
||||
"recurrence_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_master_id_recurrence_id` ON `${TABLE_NAME}` (`master_id`, `recurrence_id`)"
|
||||
},
|
||||
{
|
||||
"name": "index_tasks_is_dirty",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"is_dirty"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_is_dirty` ON `${TABLE_NAME}` (`is_dirty`)"
|
||||
},
|
||||
{
|
||||
"name": "index_tasks_list_id_uid_recurrence_id",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"list_id",
|
||||
"uid",
|
||||
"recurrence_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tasks_list_id_uid_recurrence_id` ON `${TABLE_NAME}` (`list_id`, `uid`, `recurrence_id`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "task_lists",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"list_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "tasks",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"master_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "tasks",
|
||||
"onDelete": "SET NULL",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"parent_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "task_alarms",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `task_id` INTEGER NOT NULL, `minutes_before` INTEGER NOT NULL, `reference` TEXT NOT NULL DEFAULT 'DUE', `message` TEXT, FOREIGN KEY(`task_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "taskId",
|
||||
"columnName": "task_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "minutesBefore",
|
||||
"columnName": "minutes_before",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "reference",
|
||||
"columnName": "reference",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "'DUE'"
|
||||
},
|
||||
{
|
||||
"fieldPath": "message",
|
||||
"columnName": "message",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_task_alarms_task_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"task_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_task_alarms_task_id` ON `${TABLE_NAME}` (`task_id`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "tasks",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"task_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '14b159c51416ba27653f6320ac7f6f03')"
|
||||
]
|
||||
}
|
||||
}
|
||||
Binary file not shown.
-9
@@ -212,15 +212,6 @@ class OneShotImportTest {
|
||||
assertThat(db.alarms().all().map { it.message }).doesNotContain("Errands")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun importsCategoryPropertiesAsTags() {
|
||||
importer.importFrom(fixture())
|
||||
val tasks = taskRows()
|
||||
|
||||
assertThat(tasks.getValue("Buy milk").categories).containsExactly("Errands")
|
||||
assertThat(tasks.getValue("Ship the release").categories).isEmpty()
|
||||
}
|
||||
|
||||
// --- running it -----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
|
||||
+21
-34
@@ -8,7 +8,27 @@ import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/** Migrations, proven against the committed schemas in `app/schemas/`. */
|
||||
/**
|
||||
* The migration harness, proven against the committed schema in `app/schemas/`.
|
||||
*
|
||||
* There is one schema version today, so all there is to assert is that the helper
|
||||
* can build v1 from the exported JSON, seed it, and validate it back — i.e. the
|
||||
* export, the assets wiring and the identity hash all line up. That is the point:
|
||||
* the first real migration only has to add its own case.
|
||||
*
|
||||
* **Adding a v1 → v2 case.** When sync adds columns, bump [TasksDatabase]'s
|
||||
* `version`, let KSP export `2.json`, declare the `Migration(1, 2)` next to the
|
||||
* database, and add a test here shaped like this:
|
||||
*
|
||||
* ```
|
||||
* helper.createDatabase(TEST_DB, 1).use { db ->
|
||||
* db.execSQL("INSERT INTO task_lists (name, color) VALUES ('Groceries', 0)")
|
||||
* }
|
||||
* helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2).use { db ->
|
||||
* // read the seeded rows back — validation proves the shape, not the data
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TasksDatabaseMigrationTest {
|
||||
|
||||
@@ -42,39 +62,6 @@ class TasksDatabaseMigrationTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun liftsCategoriesOutOfTheResidueWithoutDirtyingTheTask() {
|
||||
helper.createDatabase(TEST_DB, 1).use { db ->
|
||||
db.execSQL("INSERT INTO task_lists (id, name, color) VALUES (1, 'Work', 0)")
|
||||
db.execSQL(
|
||||
"INSERT INTO tasks (id, list_id, uid, title, unknown_properties) VALUES (?, 1, ?, ?, ?)",
|
||||
arrayOf<Any?>(1, "uid-1", "Claimed", "X-FOO:bar\r\nCATEGORIES:Work,Home\r\n"),
|
||||
)
|
||||
db.execSQL(
|
||||
"INSERT INTO tasks (id, list_id, uid, title, unknown_properties) VALUES (?, 1, ?, ?, ?)",
|
||||
arrayOf<Any?>(2, "uid-2", "Split", "CATEGORIES:a\r\nCATEGORIES:b\r\n"),
|
||||
)
|
||||
db.execSQL("INSERT INTO tasks (id, list_id, uid, title) VALUES (3, 1, 'uid-3', 'Untagged')")
|
||||
}
|
||||
|
||||
helper.runMigrationsAndValidate(TEST_DB, 2, true, TasksDatabase.MIGRATION_1_2).use { db ->
|
||||
db.query("SELECT categories, unknown_properties, is_dirty FROM tasks ORDER BY id").use { c ->
|
||||
c.moveToNext()
|
||||
assertThat(c.getString(0)).isEqualTo("Work,Home")
|
||||
assertThat(c.getString(1)).doesNotContain("CATEGORIES")
|
||||
assertThat(c.getString(1)).contains("X-FOO:bar")
|
||||
assertThat(c.getInt(2)).isEqualTo(0)
|
||||
|
||||
c.moveToNext()
|
||||
assertThat(c.getString(0)).isEqualTo("a,b")
|
||||
assertThat(c.getString(1)).isEqualTo("CATEGORIES:a\r\nCATEGORIES:b\r\n")
|
||||
|
||||
c.moveToNext()
|
||||
assertThat(c.getString(0)).isEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TEST_DB = "migration-test.db"
|
||||
}
|
||||
|
||||
-13
@@ -107,17 +107,6 @@ class ExternalImportTest {
|
||||
assertThat(importer.hasRun.first()).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun copiesTagsCleanedUp() = runBlocking {
|
||||
source.lists = listOf(list(7, "Errands"))
|
||||
source.tasks = mapOf(7L to listOf(task(100, "Milk", tags = listOf("Shop", " Shop ", "", "Home"))))
|
||||
|
||||
importer.run()
|
||||
|
||||
assertThat(db.tasks().tasks(listId = null, includeCompleted = true).single().task.categories)
|
||||
.containsExactly("Shop", "Home").inOrder()
|
||||
}
|
||||
|
||||
/** Every list arrives device-only: the account belongs to the sync app. */
|
||||
@Test
|
||||
fun importedListsAreDeviceOnly() = runBlocking {
|
||||
@@ -252,7 +241,6 @@ class ExternalImportTest {
|
||||
title: String,
|
||||
uid: String? = "uid-$id",
|
||||
parentId: Long? = null,
|
||||
tags: List<String> = emptyList(),
|
||||
) = ExportTask(
|
||||
taskId = id,
|
||||
uid = uid,
|
||||
@@ -272,7 +260,6 @@ class ExternalImportTest {
|
||||
rrule = null,
|
||||
rdate = null,
|
||||
parentId = parentId,
|
||||
tags = tags,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- CalDAV sync and UnifiedPush; everything here needs the network. -->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- CalDAV sync. ACCESS_NETWORK_STATE is merged in by work-runtime anyway,
|
||||
but it shows in F-Droid's permission diff, so declare it deliberately
|
||||
rather than letting it appear from nowhere.
|
||||
|
||||
READ_SYNC_SETTINGS / WRITE_SYNC_SETTINGS are what the ContentResolver
|
||||
sync APIs need. No FOREGROUND_SERVICE: sync is a plain worker, and the
|
||||
dataSync FGS type would bring the Android 15 six-hours-per-24 budget
|
||||
(whose failure mode is a fatal RemoteServiceException) and a Play
|
||||
requirement for a video demo.
|
||||
|
||||
Two more permissions appear in the merged manifest without being
|
||||
declared here, and both come from work-runtime: WAKE_LOCK, and
|
||||
FOREGROUND_SERVICE. The latter is not us taking the FGS route — below
|
||||
API 31 WorkManager implements expedited work with a foreground service,
|
||||
and minSdk is 29, so it is load-bearing for the "Sync now" button.
|
||||
Removing it with tools:node="remove" would break expedited work on
|
||||
exactly the older devices that need it most. Noted because it shows in
|
||||
F-Droid's permission diff and would otherwise look unexplained. -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
|
||||
|
||||
<queries>
|
||||
<!-- Custom Tabs provider detection. Without this entry it silently finds
|
||||
nothing on API 30+, and the Nextcloud login flow falls back to an
|
||||
external browser for no visible reason. -->
|
||||
<intent>
|
||||
<action android:name="android.support.customtabs.action.CustomTabsService" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application android:networkSecurityConfig="@xml/network_security_config">
|
||||
|
||||
<!-- Sync plumbing. The stub provider exists only to give the sync
|
||||
adapter an authority to register against: Agendula publishes no real
|
||||
ContentProvider since :provider was deleted, and without an authority
|
||||
ContentService.hasAuthorityAccess() makes every ContentResolver sync
|
||||
call a silent no-op at targetSdk >= 34. -->
|
||||
<provider
|
||||
android:name=".data.sync.SyncStubProvider"
|
||||
android:authorities="${applicationId}.sync"
|
||||
android:exported="false"
|
||||
android:syncable="true" />
|
||||
|
||||
<!-- Exported and guarded by ACCOUNT_MANAGER. Note that
|
||||
android.permission.ACCOUNT_AUTHENTICATOR does not exist. -->
|
||||
<service
|
||||
android:name=".data.sync.AuthenticatorService"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.ACCOUNT_MANAGER">
|
||||
<intent-filter>
|
||||
<action android:name="android.accounts.AccountAuthenticator" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.accounts.AccountAuthenticator"
|
||||
android:resource="@xml/authenticator" />
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".data.sync.SyncAdapterService"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BIND_SYNC_ADAPTER">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.SyncAdapter" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.content.SyncAdapter"
|
||||
android:resource="@xml/sync_adapter" />
|
||||
</service>
|
||||
|
||||
<!-- UnifiedPush: the connector binds this to deliver endpoints and
|
||||
WebDAV-Push messages. Not exported; the connector's own receiver is
|
||||
what distributors talk to. -->
|
||||
<service
|
||||
android:name=".data.sync.push.AgendulaPushService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -1,131 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.data.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
import de.jeanlucmakiola.agendula.data.sync.AccountCreator
|
||||
import de.jeanlucmakiola.agendula.data.sync.AccountRepository
|
||||
import de.jeanlucmakiola.agendula.data.sync.CalDavGateway
|
||||
import de.jeanlucmakiola.agendula.data.sync.LoginFlowRecord
|
||||
import de.jeanlucmakiola.agendula.data.sync.OkHttpCalDavGateway
|
||||
import de.jeanlucmakiola.agendula.data.sync.PendingLoginFlowStore
|
||||
import de.jeanlucmakiola.agendula.data.sync.RemoteListRepository
|
||||
import de.jeanlucmakiola.agendula.data.sync.RemoteLists
|
||||
import de.jeanlucmakiola.agendula.data.sync.SyncNoticeNotifier
|
||||
import de.jeanlucmakiola.agendula.data.sync.SyncOnEdit
|
||||
import de.jeanlucmakiola.agendula.data.sync.SyncRequests
|
||||
import de.jeanlucmakiola.agendula.data.sync.SyncTrigger
|
||||
import de.jeanlucmakiola.agendula.data.sync.push.PushRegistrar
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.LocalWriteListener
|
||||
import javax.inject.Provider
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** See [CredentialsDataStore] for why this is a separate file. */
|
||||
private val Context.credentialsDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||
name = CREDENTIALS_DATASTORE,
|
||||
corruptionHandler = replaceCorrupted(),
|
||||
)
|
||||
|
||||
/** See [SyncStateDataStore] for why this is a separate file. */
|
||||
private val Context.syncStateDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||
name = SYNC_STATE_DATASTORE,
|
||||
corruptionHandler = replaceCorrupted(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Named here and in `backup_rules.xml` / `data_extraction_rules.xml`, which
|
||||
* exclude `datastore/$CREDENTIALS_DATASTORE.preferences_pb` by this name.
|
||||
*/
|
||||
const val CREDENTIALS_DATASTORE = "agendula_credentials"
|
||||
|
||||
/**
|
||||
* Named here and in `backup_rules.xml` / `data_extraction_rules.xml`, which
|
||||
* exclude `datastore/$SYNC_STATE_DATASTORE.preferences_pb` by this name.
|
||||
*/
|
||||
const val SYNC_STATE_DATASTORE = "agendula_sync_state"
|
||||
|
||||
/** CalDAV sync: the `full` flavor's side of the seams `main` declares. */
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class SyncBindModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindCalDavGateway(impl: OkHttpCalDavGateway): CalDavGateway
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindAccountCreator(impl: AccountRepository): AccountCreator
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindLoginFlowRecord(impl: PendingLoginFlowStore): LoginFlowRecord
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindLocalWriteListener(impl: SyncOnEdit): LocalWriteListener
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindRemoteLists(impl: RemoteListRepository): RemoteLists
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindSyncRequests(impl: SyncTrigger): SyncRequests
|
||||
|
||||
@Binds
|
||||
@IntoSet
|
||||
abstract fun bindSyncNoticeChannel(impl: SyncNoticeNotifier): ChannelRefresher
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object SyncProvideModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@CredentialsDataStore
|
||||
fun provideCredentialsDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
||||
context.credentialsDataStore
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@SyncStateDataStore
|
||||
fun provideSyncStateDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
||||
context.syncStateDataStore
|
||||
|
||||
/**
|
||||
* Sync hard on app open: the periodic worker's interval is a floor, and in
|
||||
* the `rare` and `restricted` App Standby buckets it may not have run at
|
||||
* all. `KEEP` makes rescheduling idempotent, so this also repairs a schedule
|
||||
* lost to "clear app data" or to a restore.
|
||||
*/
|
||||
@Provides
|
||||
@IntoSet
|
||||
fun syncOnOpenHook(
|
||||
accounts: Provider<AccountRepository>,
|
||||
syncTrigger: Provider<SyncTrigger>,
|
||||
pendingLoginFlows: Provider<PendingLoginFlowStore>,
|
||||
push: Provider<PushRegistrar>,
|
||||
): LaunchHook = LaunchHook {
|
||||
runCatching {
|
||||
accounts.get().rescheduleAll()
|
||||
accounts.get().syncable().forEach { syncTrigger.get().enqueue(it.displayName) }
|
||||
// A login flow the previous process died in the middle of.
|
||||
// Its password, if the user approved, exists nowhere else.
|
||||
pendingLoginFlows.get().reclaim()
|
||||
}
|
||||
// Last and on its own: it waits on the network, and must not hold up
|
||||
// the reclaim above. Re-registering on open is what the connector
|
||||
// recommends.
|
||||
runCatching { push.get().updateAll() }
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.data.di
|
||||
|
||||
import javax.inject.Qualifier
|
||||
|
||||
/**
|
||||
* Marks the DataStore holding **only** the Keystore-encrypted app passwords.
|
||||
*
|
||||
* A separate file from `agendula_prefs` on purpose: Auto Backup includes
|
||||
* `datastore/`, and a restored ciphertext is permanently undecryptable because
|
||||
* Keystore keys are non-exportable. Its own file is what lets the backup rules
|
||||
* exclude the credentials and nothing else — excluding the whole database or
|
||||
* all of DataStore would trade a latent bug for a live one.
|
||||
*/
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class CredentialsDataStore
|
||||
|
||||
/**
|
||||
* Marks the DataStore holding per-device **sync bookkeeping** — the quarantine
|
||||
* counters and the full-reconciliation clock.
|
||||
*
|
||||
* Its own file for the same reason the credentials have one: Auto Backup
|
||||
* includes `datastore/`, and every value in here is a statement about *this*
|
||||
* device's conversation with a server. Restored onto a new install they are all
|
||||
* lies, and two of them are dangerous — a restored "reconciled recently" makes
|
||||
* the engine trust a sync token for another day, which is precisely the silently
|
||||
* pruned change log the full path exists to catch, and a restored quarantine
|
||||
* count silently skips resources that were never tried here.
|
||||
*
|
||||
* Not user data, so nothing is lost by excluding it.
|
||||
*/
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class SyncStateDataStore
|
||||
@@ -1,35 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.ui.onboarding
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.agendula.ui.accounts.add.ADD_ACCOUNT_STEPS
|
||||
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountScreen
|
||||
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountViewModel
|
||||
|
||||
/** The inline wizard's shortest length, before a provider is picked. */
|
||||
internal const val ACCOUNT_STEP_SLOTS = ADD_ACCOUNT_STEPS
|
||||
|
||||
/** [OnboardingStep.Account]: the add-account wizard, given the whole screen. */
|
||||
@Composable
|
||||
internal fun AccountStep(state: OnboardingUiState, viewModel: OnboardingViewModel) {
|
||||
// The same instance the wizard resolves for itself, so its length can be
|
||||
// read here without keeping a second copy of its state.
|
||||
val account: AddAccountViewModel = hiltViewModel()
|
||||
val accountState by account.state.collectAsStateWithLifecycle()
|
||||
// ⚠️ The wizard grows a step for a provider that needs an app password
|
||||
// minted first, and the outer bar has to grow with it — otherwise first
|
||||
// run counts one flow while the screen inside it counts another.
|
||||
LaunchedEffect(accountState.totalSteps) {
|
||||
viewModel.onAccountStepsChanged(accountState.totalSteps)
|
||||
}
|
||||
AddAccountScreen(
|
||||
onDone = { viewModel.onAccountFinished(added = true) },
|
||||
onBack = { viewModel.onAccountFinished(added = false) },
|
||||
stepOffset = state.position - 1,
|
||||
totalSteps = state.total,
|
||||
viewModel = account,
|
||||
)
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.ui.settings
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
|
||||
import de.jeanlucmakiola.agendula.ui.accounts.AccountDetailScreen
|
||||
import de.jeanlucmakiola.agendula.ui.accounts.AccountsScreen
|
||||
import de.jeanlucmakiola.agendula.ui.accounts.AccountsViewModel
|
||||
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountScreen
|
||||
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountViewModel
|
||||
|
||||
/** Settings → Accounts and the screens under it, layered over the hub. */
|
||||
@Composable
|
||||
internal fun AccountSections(
|
||||
section: SettingsSection?,
|
||||
initialAccountId: Long?,
|
||||
onSection: (SettingsSection?) -> Unit,
|
||||
) {
|
||||
// Hoisted so the add flow can refresh the list it returns to.
|
||||
val accountsViewModel: AccountsViewModel = hiltViewModel()
|
||||
// Shared with the add flow's own lookup, so "sign in again" can prefill it.
|
||||
val addAccountViewModel: AddAccountViewModel = hiltViewModel()
|
||||
val signInAgain: (AccountEntity) -> Unit = { account ->
|
||||
addAccountViewModel.startReauthentication(account.id, account.principalUrl, account.username)
|
||||
onSection(SettingsSection.AddAccount)
|
||||
}
|
||||
// Which account the detail screen is showing; the section alone cannot say.
|
||||
var openAccount by rememberSaveable(initialAccountId) { mutableStateOf(initialAccountId) }
|
||||
|
||||
// Accounts stays composed under Add account, for the same reason Storage
|
||||
// stays composed under Export: the deeper screen slides over it.
|
||||
val accountsOpen = section == SettingsSection.Accounts ||
|
||||
section?.parent == SettingsSection.Accounts
|
||||
SlideInSection(visible = accountsOpen) {
|
||||
AccountsScreen(
|
||||
onAddAccount = {
|
||||
// A sign-in abandoned half-way must not turn this into one.
|
||||
if (addAccountViewModel.state.value.reauthenticating) addAccountViewModel.onStartOver()
|
||||
onSection(SettingsSection.AddAccount)
|
||||
},
|
||||
onSignInAgain = signInAgain,
|
||||
onOpenAccount = {
|
||||
openAccount = it
|
||||
onSection(SettingsSection.Account)
|
||||
},
|
||||
onOpenStorage = { onSection(SettingsSection.Storage) },
|
||||
onBack = { onSection(null) },
|
||||
viewModel = accountsViewModel,
|
||||
)
|
||||
}
|
||||
SlideInSection(visible = section == SettingsSection.Account) {
|
||||
openAccount?.let { id ->
|
||||
AccountDetailScreen(
|
||||
accountId = id,
|
||||
onBack = { onSection(SettingsSection.Accounts) },
|
||||
onRemoved = { onSection(SettingsSection.Accounts) },
|
||||
onSignInAgain = signInAgain,
|
||||
onOpenStorage = { onSection(SettingsSection.Storage) },
|
||||
viewModel = accountsViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
SlideInSection(visible = section == SettingsSection.AddAccount) {
|
||||
AddAccountScreen(
|
||||
// The list stays composed underneath, so nothing re-runs its
|
||||
// init and OnResume never fires on a section change. It no longer
|
||||
// needs to: the accounts come from an observed query, so a new
|
||||
// account — and every later sync — arrives on its own.
|
||||
onDone = { onSection(SettingsSection.Accounts) },
|
||||
onBack = { onSection(SettingsSection.Accounts) },
|
||||
viewModel = addAccountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,29 @@
|
||||
<uses-permission android:name="org.tasks.permission.READ_TASKS" />
|
||||
<uses-permission android:name="org.tasks.permission.WRITE_TASKS" />
|
||||
|
||||
<!-- CalDAV sync. ACCESS_NETWORK_STATE is merged in by work-runtime anyway,
|
||||
but it shows in F-Droid's permission diff, so declare it deliberately
|
||||
rather than letting it appear from nowhere.
|
||||
|
||||
READ_SYNC_SETTINGS / WRITE_SYNC_SETTINGS are what the ContentResolver
|
||||
sync APIs need. No FOREGROUND_SERVICE: sync is a plain worker, and the
|
||||
dataSync FGS type would bring the Android 15 six-hours-per-24 budget
|
||||
(whose failure mode is a fatal RemoteServiceException) and a Play
|
||||
requirement for a video demo.
|
||||
|
||||
Two more permissions appear in the merged manifest without being
|
||||
declared here, and both come from work-runtime: WAKE_LOCK, and
|
||||
FOREGROUND_SERVICE. The latter is not us taking the FGS route — below
|
||||
API 31 WorkManager implements expedited work with a foreground service,
|
||||
and minSdk is 29, so it is load-bearing for the "Sync now" button.
|
||||
Removing it with tools:node="remove" would break expedited work on
|
||||
exactly the older devices that need it most. Noted because it shows in
|
||||
F-Droid's permission diff and would otherwise look unexplained. -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
|
||||
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<!-- Exact due-time reminders. User-granted: USE_EXACT_ALARM is reserved for
|
||||
@@ -32,6 +55,12 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent>
|
||||
<!-- Custom Tabs provider detection. Without this entry it silently finds
|
||||
nothing on API 30+, and the Nextcloud login flow falls back to an
|
||||
external browser for no visible reason. -->
|
||||
<intent>
|
||||
<action android:name="android.support.customtabs.action.CustomTabsService" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
@@ -42,6 +71,7 @@
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:localeConfig="@xml/locales_config"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Agendula"
|
||||
@@ -155,58 +185,6 @@
|
||||
android:resource="@xml/appwidget_info_tasks" />
|
||||
</receiver>
|
||||
|
||||
<!-- "Today" home-screen widget (Glance): a progress ring for today's tasks. -->
|
||||
<receiver
|
||||
android:name=".widget.TodayWidgetReceiver"
|
||||
android:label="@string/widget_today_label"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/appwidget_info_today" />
|
||||
</receiver>
|
||||
|
||||
<!-- "Up next" home-screen widget (Glance): the single nearest due task. -->
|
||||
<receiver
|
||||
android:name=".widget.UpNextWidgetReceiver"
|
||||
android:label="@string/widget_up_next_label"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/appwidget_info_up_next" />
|
||||
</receiver>
|
||||
|
||||
<!-- "Lists" home-screen widget (Glance): one shortcut tile per list. -->
|
||||
<receiver
|
||||
android:name=".widget.ListsWidgetReceiver"
|
||||
android:label="@string/widget_lists_label"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/appwidget_info_lists" />
|
||||
</receiver>
|
||||
|
||||
<!-- "Week" home-screen widget (Glance): a seven-day strip of task counts. -->
|
||||
<receiver
|
||||
android:name=".widget.WeekWidgetReceiver"
|
||||
android:label="@string/widget_week_label"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/appwidget_info_week" />
|
||||
</receiver>
|
||||
|
||||
<!-- Per-widget list picker, launched by the host on placement / reconfigure. -->
|
||||
<activity
|
||||
android:name=".widget.TaskWidgetConfigActivity"
|
||||
@@ -233,6 +211,54 @@
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- Sync plumbing. The stub provider exists only to give the sync
|
||||
adapter an authority to register against: Agendula publishes no real
|
||||
ContentProvider since :provider was deleted, and without an authority
|
||||
ContentService.hasAuthorityAccess() makes every ContentResolver sync
|
||||
call a silent no-op at targetSdk >= 34. -->
|
||||
<provider
|
||||
android:name=".data.sync.SyncStubProvider"
|
||||
android:authorities="${applicationId}.sync"
|
||||
android:exported="false"
|
||||
android:syncable="true" />
|
||||
|
||||
<!-- Exported and guarded by ACCOUNT_MANAGER. Note that
|
||||
android.permission.ACCOUNT_AUTHENTICATOR does not exist. -->
|
||||
<service
|
||||
android:name=".data.sync.AuthenticatorService"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.ACCOUNT_MANAGER">
|
||||
<intent-filter>
|
||||
<action android:name="android.accounts.AccountAuthenticator" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.accounts.AccountAuthenticator"
|
||||
android:resource="@xml/authenticator" />
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".data.sync.SyncAdapterService"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BIND_SYNC_ADAPTER">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.SyncAdapter" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.content.SyncAdapter"
|
||||
android:resource="@xml/sync_adapter" />
|
||||
</service>
|
||||
|
||||
<!-- UnifiedPush: the connector binds this to deliver endpoints and
|
||||
WebDAV-Push messages. Not exported; the connector's own receiver is
|
||||
what distributors talk to. -->
|
||||
<service
|
||||
android:name=".data.sync.push.AgendulaPushService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- WorkManager's on-demand initialisation. Removing the default
|
||||
initializer is what lets AgendulaApp supply a HiltWorkerFactory, so
|
||||
@HiltWorker workers can take injected dependencies. -->
|
||||
|
||||
@@ -10,10 +10,10 @@ import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
||||
import de.jeanlucmakiola.agendula.data.di.ChannelRefresher
|
||||
import de.jeanlucmakiola.agendula.data.reminders.ReminderMaintenanceWorker
|
||||
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
|
||||
import de.jeanlucmakiola.agendula.data.reminders.TaskNotifier
|
||||
import de.jeanlucmakiola.agendula.data.sync.SyncNoticeNotifier
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||
import de.jeanlucmakiola.agendula.data.tasks.StartupGate
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.DatabaseCheckpoint
|
||||
@@ -105,7 +105,7 @@ class AgendulaApp : Application(), Configuration.Provider {
|
||||
val entryPoint = EntryPointAccessors.fromApplication(this, AppEntryPoint::class.java)
|
||||
runCatching {
|
||||
entryPoint.taskNotifier().refreshChannel()
|
||||
entryPoint.channelRefreshers().forEach { it.refreshChannel() }
|
||||
entryPoint.syncNoticeNotifier().refreshChannel()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ class AgendulaApp : Application(), Configuration.Provider {
|
||||
fun applicationScope(): CoroutineScope
|
||||
fun databaseCheckpoint(): DatabaseCheckpoint
|
||||
fun taskNotifier(): TaskNotifier
|
||||
fun channelRefreshers(): Set<@JvmSuppressWildcards ChannelRefresher>
|
||||
fun syncNoticeNotifier(): SyncNoticeNotifier
|
||||
fun taskWidgetUpdater(): TaskWidgetUpdater
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.agendula.data.di.LaunchHook
|
||||
import de.jeanlucmakiola.agendula.data.sync.SyncNoticeNotifier
|
||||
import de.jeanlucmakiola.agendula.domain.SmartList
|
||||
import de.jeanlucmakiola.agendula.ui.imports.importIntentUri
|
||||
import de.jeanlucmakiola.agendula.ui.navigation.AppShortcuts
|
||||
@@ -32,6 +33,10 @@ import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.agendula.ui.RootScreen
|
||||
import de.jeanlucmakiola.agendula.ui.crash.CrashReportActivity
|
||||
import de.jeanlucmakiola.agendula.ui.navigation.NavRequest
|
||||
import de.jeanlucmakiola.agendula.data.sync.AccountRepository
|
||||
import de.jeanlucmakiola.agendula.data.sync.PendingLoginFlowStore
|
||||
import de.jeanlucmakiola.agendula.data.sync.SyncTrigger
|
||||
import de.jeanlucmakiola.agendula.data.sync.push.PushRegistrar
|
||||
import de.jeanlucmakiola.agendula.ui.settings.SettingsViewModel
|
||||
import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme
|
||||
import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
||||
@@ -50,6 +55,14 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
@Inject lateinit var launchHooks: Set<@JvmSuppressWildcards LaunchHook>
|
||||
|
||||
@Inject lateinit var accounts: AccountRepository
|
||||
|
||||
@Inject lateinit var syncTrigger: SyncTrigger
|
||||
|
||||
@Inject lateinit var pendingLoginFlows: PendingLoginFlowStore
|
||||
|
||||
@Inject lateinit var push: PushRegistrar
|
||||
|
||||
// A captured crash report awaiting the user's decision, surfaced as a dialog
|
||||
// over the app on the next launch (the single-crash path). A startup
|
||||
// crash-loop is handled out of band, before setContent — see below.
|
||||
@@ -81,9 +94,34 @@ class MainActivity : ComponentActivity() {
|
||||
// Surface a single captured crash as a dialog on the next launch.
|
||||
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
||||
|
||||
// Each hook on its own, so one waiting on the network holds up no other.
|
||||
// Sync hard on app open: the periodic worker's interval is a floor, and
|
||||
// in the `rare` and `restricted` App Standby buckets it may not have run
|
||||
// at all. `KEEP` makes rescheduling idempotent, so this also repairs a
|
||||
// schedule lost to "clear app data" or to a restore.
|
||||
//
|
||||
// ⚠️ Only on a genuine open. This activity declares no `configChanges`,
|
||||
// so onCreate runs again on every rotation, theme switch, locale change
|
||||
// and font-scale change — each of which would otherwise start a fresh
|
||||
// network sync the moment the previous one finished.
|
||||
if (savedInstanceState == null) {
|
||||
launchHooks.forEach { hook -> lifecycleScope.launch { runCatching { hook.onLaunch(intent) } } }
|
||||
lifecycleScope.launch {
|
||||
runCatching {
|
||||
accounts.rescheduleAll()
|
||||
accounts.syncable().forEach { syncTrigger.enqueue(it.displayName) }
|
||||
// A login flow the previous process died in the middle of.
|
||||
// Its password, if the user approved, exists nowhere else.
|
||||
pendingLoginFlows.reclaim()
|
||||
}
|
||||
// Last and on its own: it waits on the network, and must not
|
||||
// hold up the reclaim above. Re-registering on open is what
|
||||
// the connector recommends.
|
||||
runCatching { push.updateAll() }
|
||||
}
|
||||
}
|
||||
|
||||
// Variant hooks: the demo seeder in debug builds, nothing in release.
|
||||
if (savedInstanceState == null) {
|
||||
lifecycleScope.launch { launchHooks.forEach { runCatching { it.onLaunch(intent) } } }
|
||||
}
|
||||
setContent {
|
||||
val settingsViewModel: SettingsViewModel = hiltViewModel()
|
||||
@@ -142,16 +180,12 @@ class MainActivity : ComponentActivity() {
|
||||
const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID"
|
||||
const val EXTRA_OCCURRENCE_START = "de.jeanlucmakiola.agendula.extra.OCCURRENCE_START"
|
||||
private const val EXTRA_OPEN_ACCOUNTS = "de.jeanlucmakiola.agendula.extra.OPEN_ACCOUNTS"
|
||||
|
||||
/** The account a sign-in notification is about, on the intent it opens. */
|
||||
const val EXTRA_SIGN_IN_ACCOUNT_ID = "de.jeanlucmakiola.agendula.extra.SIGN_IN_ACCOUNT_ID"
|
||||
const val ACTION_NEW_TASK = "de.jeanlucmakiola.agendula.action.NEW_TASK"
|
||||
const val ACTION_TODAY = "de.jeanlucmakiola.agendula.action.TODAY"
|
||||
private const val ACTION_OPEN_SMART = "de.jeanlucmakiola.agendula.action.OPEN_SMART"
|
||||
private const val ACTION_OPEN_LIST = "de.jeanlucmakiola.agendula.action.OPEN_LIST"
|
||||
private const val EXTRA_SMART_LIST = "de.jeanlucmakiola.agendula.extra.SMART_LIST"
|
||||
private const val EXTRA_LIST_ID = "de.jeanlucmakiola.agendula.extra.LIST_ID"
|
||||
private const val EXTRA_PRESET_LIST_ID = "de.jeanlucmakiola.agendula.extra.PRESET_LIST_ID"
|
||||
private const val SHARED_TITLE_LIMIT = 500
|
||||
private const val NO_OCCURRENCE = -1L
|
||||
|
||||
@@ -172,14 +206,9 @@ class MainActivity : ComponentActivity() {
|
||||
.putExtra(EXTRA_OPEN_ACCOUNTS, true)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
/**
|
||||
* The launcher shortcut's, Quick Settings tile's and a widget's "New
|
||||
* task". [listId] preset the list when it names exactly one; the data
|
||||
* URI keeps each widget's PendingIntent apart from the others.
|
||||
*/
|
||||
fun newTaskIntent(context: Context, listId: Long? = null): Intent =
|
||||
Intent(ACTION_NEW_TASK, listId?.let { "agendula://newtask/$it".toUri() }, context, MainActivity::class.java)
|
||||
.putExtra(EXTRA_PRESET_LIST_ID, listId ?: -1L)
|
||||
/** The launcher shortcut's and Quick Settings tile's "New task". */
|
||||
fun newTaskIntent(context: Context): Intent =
|
||||
Intent(ACTION_NEW_TASK, null, context, MainActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
fun todayIntent(context: Context): Intent =
|
||||
@@ -200,15 +229,11 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
internal fun navRequestOf(intent: Intent?): NavRequest? {
|
||||
if (intent == null) return null
|
||||
if (BuildConfig.SYNC_ENABLED) {
|
||||
intent.getLongExtra(EXTRA_SIGN_IN_ACCOUNT_ID, -1L).takeIf { it > 0L }
|
||||
?.let { return NavRequest.OpenAccount(it) }
|
||||
if (intent.getBooleanExtra(EXTRA_OPEN_ACCOUNTS, false)) return NavRequest.OpenAccounts
|
||||
}
|
||||
intent.getLongExtra(SyncNoticeNotifier.EXTRA_SIGN_IN_ACCOUNT_ID, -1L).takeIf { it > 0L }
|
||||
?.let { return NavRequest.OpenAccount(it) }
|
||||
if (intent.getBooleanExtra(EXTRA_OPEN_ACCOUNTS, false)) return NavRequest.OpenAccounts
|
||||
when (intent.action) {
|
||||
ACTION_NEW_TASK -> return NavRequest.NewTask(
|
||||
listId = intent.getLongExtra(EXTRA_PRESET_LIST_ID, -1L).takeIf { it > 0L },
|
||||
)
|
||||
ACTION_NEW_TASK -> return NavRequest.NewTask()
|
||||
ACTION_TODAY -> return NavRequest.OpenSmart(SmartList.TODAY)
|
||||
ACTION_OPEN_SMART -> intent.getStringExtra(EXTRA_SMART_LIST)
|
||||
?.let { name -> SmartList.entries.firstOrNull { it.name == name } }
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.data.di
|
||||
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.Multibinds
|
||||
|
||||
/**
|
||||
* A notification channel a build variant adds, re-created in the current
|
||||
* language on start and on a configuration change. The `full` flavor
|
||||
* contributes the sync notices; `offline` has none.
|
||||
*/
|
||||
fun interface ChannelRefresher {
|
||||
fun refreshChannel()
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class ChannelRefresherModule {
|
||||
@Multibinds
|
||||
abstract fun channelRefreshers(): Set<ChannelRefresher>
|
||||
}
|
||||
@@ -12,6 +12,13 @@ import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.jeanlucmakiola.agendula.data.sync.AccountCreator
|
||||
import de.jeanlucmakiola.agendula.data.sync.AccountRepository
|
||||
import de.jeanlucmakiola.agendula.data.sync.CalDavGateway
|
||||
import de.jeanlucmakiola.agendula.data.sync.LoginFlowRecord
|
||||
import de.jeanlucmakiola.agendula.data.sync.PendingLoginFlowStore
|
||||
import de.jeanlucmakiola.agendula.data.sync.SyncOnEdit
|
||||
import de.jeanlucmakiola.agendula.data.sync.OkHttpCalDavGateway
|
||||
import de.jeanlucmakiola.agendula.data.tasks.AndroidProviderEnvironment
|
||||
import de.jeanlucmakiola.agendula.data.tasks.AndroidTasksDataSource
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ModeRoutingTasksDataSource
|
||||
@@ -20,6 +27,7 @@ import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepositoryImpl
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.LocalWriteListener
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
||||
@@ -43,13 +51,37 @@ import javax.inject.Singleton
|
||||
* reconciled", and the credential store to accounts that ask to be signed in
|
||||
* again — all states the app already knows how to be in, unlike a launch loop.
|
||||
*/
|
||||
internal fun replaceCorrupted() = ReplaceFileCorruptionHandler { emptyPreferences() }
|
||||
private fun replaceCorrupted() = ReplaceFileCorruptionHandler { emptyPreferences() }
|
||||
|
||||
private val Context.agendulaDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||
name = "agendula_prefs",
|
||||
corruptionHandler = replaceCorrupted(),
|
||||
)
|
||||
|
||||
/** See [CredentialsDataStore] for why this is a separate file. */
|
||||
private val Context.credentialsDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||
name = CREDENTIALS_DATASTORE,
|
||||
corruptionHandler = replaceCorrupted(),
|
||||
)
|
||||
|
||||
/** See [SyncStateDataStore] for why this is a separate file. */
|
||||
private val Context.syncStateDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||
name = SYNC_STATE_DATASTORE,
|
||||
corruptionHandler = replaceCorrupted(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Named here and in `backup_rules.xml` / `data_extraction_rules.xml`, which
|
||||
* exclude `datastore/$CREDENTIALS_DATASTORE.preferences_pb` by this name.
|
||||
*/
|
||||
const val CREDENTIALS_DATASTORE = "agendula_credentials"
|
||||
|
||||
/**
|
||||
* Named here and in `backup_rules.xml` / `data_extraction_rules.xml`, which
|
||||
* exclude `datastore/$SYNC_STATE_DATASTORE.preferences_pb` by this name.
|
||||
*/
|
||||
const val SYNC_STATE_DATASTORE = "agendula_sync_state"
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class DataBindModule {
|
||||
@@ -62,6 +94,22 @@ abstract class DataBindModule {
|
||||
@Singleton
|
||||
abstract fun bindProviderEnvironment(impl: AndroidProviderEnvironment): ProviderEnvironment
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindCalDavGateway(impl: OkHttpCalDavGateway): CalDavGateway
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindAccountCreator(impl: AccountRepository): AccountCreator
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindLoginFlowRecord(impl: PendingLoginFlowStore): LoginFlowRecord
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindLocalWriteListener(impl: SyncOnEdit): LocalWriteListener
|
||||
|
||||
// Deliberately unqualified-free of the routing above: this is the external
|
||||
// store itself, for the one caller that has to read it while another store is
|
||||
// the active one.
|
||||
@@ -80,6 +128,18 @@ object DataProvideModule {
|
||||
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
||||
context.agendulaDataStore
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@CredentialsDataStore
|
||||
fun provideCredentialsDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
||||
context.credentialsDataStore
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@SyncStateDataStore
|
||||
fun provideSyncStateDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
||||
context.syncStateDataStore
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideTasksDatabase(@ApplicationContext context: Context): TasksDatabase =
|
||||
@@ -89,7 +149,6 @@ object DataProvideModule {
|
||||
// backed-up `.db` does not. The backup rules carry all three files and
|
||||
// the app checkpoints on ON_STOP.
|
||||
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
|
||||
.addMigrations(TasksDatabase.MIGRATION_1_2)
|
||||
.build()
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,8 +7,9 @@ import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.Multibinds
|
||||
|
||||
/**
|
||||
* Something a build variant runs on a genuine app open (not on a configuration
|
||||
* change): sync in the `full` flavor, the demo seeder in debug builds.
|
||||
* Something a build variant wants to run when the app is launched with [Intent].
|
||||
* Release builds contribute none; the debug source set adds the demo seeder, so
|
||||
* that class never ships.
|
||||
*/
|
||||
fun interface LaunchHook {
|
||||
suspend fun onLaunch(intent: Intent)
|
||||
|
||||
@@ -17,6 +17,37 @@ annotation class IoDispatcher
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class ApplicationScope
|
||||
|
||||
/**
|
||||
* Marks the DataStore holding **only** the Keystore-encrypted app passwords.
|
||||
*
|
||||
* A separate file from `agendula_prefs` on purpose: Auto Backup includes
|
||||
* `datastore/`, and a restored ciphertext is permanently undecryptable because
|
||||
* Keystore keys are non-exportable. Its own file is what lets the backup rules
|
||||
* exclude the credentials and nothing else — excluding the whole database or
|
||||
* all of DataStore would trade a latent bug for a live one.
|
||||
*/
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class CredentialsDataStore
|
||||
|
||||
/**
|
||||
* Marks the DataStore holding per-device **sync bookkeeping** — the quarantine
|
||||
* counters and the full-reconciliation clock.
|
||||
*
|
||||
* Its own file for the same reason the credentials have one: Auto Backup
|
||||
* includes `datastore/`, and every value in here is a statement about *this*
|
||||
* device's conversation with a server. Restored onto a new install they are all
|
||||
* lies, and two of them are dangerous — a restored "reconciled recently" makes
|
||||
* the engine trust a sync token for another day, which is precisely the silently
|
||||
* pruned change log the full path exists to catch, and a restored quarantine
|
||||
* count silently skips resources that were never tried here.
|
||||
*
|
||||
* Not user data, so nothing is lost by excluding it.
|
||||
*/
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class SyncStateDataStore
|
||||
|
||||
/**
|
||||
* Marks the **external** provider's [de.jeanlucmakiola.agendula.data.tasks
|
||||
* .TasksDataSource] — the OpenTasks/tasks.org path specifically, rather than
|
||||
|
||||
@@ -95,8 +95,6 @@ data class Settings(
|
||||
val pushEnabled: Boolean = true,
|
||||
/** Lists of the current store whose tasks the smart lists (and their counts) leave out. */
|
||||
val hiddenFromSmartLists: Set<Long> = emptySet(),
|
||||
/** Pre-fill a new task's start with today's date. */
|
||||
val defaultStartToday: Boolean = false,
|
||||
) {
|
||||
/** [tasks] as [filter] shows them: a smart list drops the lists kept out of it. */
|
||||
fun visibleIn(filter: TaskFilter, tasks: List<Task>): List<Task> =
|
||||
@@ -155,7 +153,6 @@ class SettingsPrefs @Inject constructor(
|
||||
.mapNotNull { entry -> entry.substringAfter("$mode:", "").toLongOrNull() }
|
||||
.toSet()
|
||||
},
|
||||
defaultStartToday = p[DEFAULT_START_TODAY] ?: false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -169,8 +166,6 @@ class SettingsPrefs @Inject constructor(
|
||||
|
||||
suspend fun setAutofocusTitle(enabled: Boolean) = dataStore.edit { it[AUTOFOCUS_TITLE] = enabled }
|
||||
|
||||
suspend fun setDefaultStartToday(enabled: Boolean) = dataStore.edit { it[DEFAULT_START_TODAY] = enabled }
|
||||
|
||||
suspend fun setTimeFormat(pref: TimeFormatPref) = dataStore.edit { it[TIME_FORMAT] = pref.name }
|
||||
|
||||
suspend fun setWeekStart(day: DayOfWeek?) = dataStore.edit {
|
||||
@@ -287,7 +282,6 @@ class SettingsPrefs @Inject constructor(
|
||||
val SYNC_INTERVAL = intPreferencesKey("sync_interval_minutes")
|
||||
val PUSH_ENABLED = booleanPreferencesKey("push_enabled")
|
||||
val SMART_LIST_HIDDEN = stringSetPreferencesKey("smart_list_hidden")
|
||||
val DEFAULT_START_TODAY = booleanPreferencesKey("default_start_today")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+36
-6
@@ -1,7 +1,6 @@
|
||||
package de.jeanlucmakiola.agendula.data.sync
|
||||
|
||||
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.agendula.data.sync.RemoteLists.Outcome
|
||||
import de.jeanlucmakiola.agendula.data.sync.push.PushStore
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
|
||||
@@ -54,7 +53,38 @@ class RemoteListRepository @Inject constructor(
|
||||
private val resolver: ProviderResolver,
|
||||
private val push: PushStore,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : RemoteLists {
|
||||
) {
|
||||
|
||||
/** Why a collection write did not happen, in a form the UI can translate. */
|
||||
sealed interface Outcome {
|
||||
data object Done : Outcome
|
||||
|
||||
/** The server said no, and will say no again. */
|
||||
data class Refused(val code: Int) : Outcome
|
||||
|
||||
/** The server could not be reached. Worth another try. */
|
||||
data object Unreachable : Outcome
|
||||
|
||||
/** This account cannot make collections at all — iCloud, Posteo, Google. */
|
||||
data object Unsupported : Outcome
|
||||
|
||||
/** Ours is a read-only share; the write belongs to whoever owns it. */
|
||||
data object ReadOnly : Outcome
|
||||
|
||||
/** The account is gone, stopped, or has no credential we can decrypt. */
|
||||
data object NoAccount : Outcome
|
||||
|
||||
/**
|
||||
* The server answered something this call cannot make sense of.
|
||||
*
|
||||
* ⚠️ Not [Unreachable]. `CollectionOutcome` is one type across create,
|
||||
* update and delete, so each of them has branches the other's method
|
||||
* can return and its own cannot — and mapping those to [Unreachable]
|
||||
* told someone sitting on wifi that they were offline. Unreachable is a
|
||||
* claim about the network, and this is not one.
|
||||
*/
|
||||
data object Unexpected : Outcome
|
||||
}
|
||||
|
||||
/**
|
||||
* The accounts a new list may be created on, freshest answer first.
|
||||
@@ -65,7 +95,7 @@ class RemoteListRepository @Inject constructor(
|
||||
* able to say so, and the only moment that costs nothing is while the user
|
||||
* is looking at the picker.
|
||||
*/
|
||||
override suspend fun creatableAccounts(): List<AccountEntity> = withContext(io) {
|
||||
suspend fun creatableAccounts(): List<AccountEntity> = withContext(io) {
|
||||
// ⚠️ Empty in External mode, whatever the accounts table holds. The
|
||||
// lists on screen then come from a third-party provider, so a row
|
||||
// inserted into ours would exist, sync, and be visible to nobody.
|
||||
@@ -90,7 +120,7 @@ class RemoteListRepository @Inject constructor(
|
||||
*
|
||||
* @return the new list's local id, or why there is none.
|
||||
*/
|
||||
override suspend fun create(
|
||||
suspend fun create(
|
||||
accountId: Long,
|
||||
name: String,
|
||||
color: Int,
|
||||
@@ -159,7 +189,7 @@ class RemoteListRepository @Inject constructor(
|
||||
* a row that has already been renamed locally by then reads as a rename that
|
||||
* worked and then quietly reverted on the next sync.
|
||||
*/
|
||||
override suspend fun rename(listId: Long, name: String, color: Int): Outcome = withContext(io) {
|
||||
suspend fun rename(listId: Long, name: String, color: Int): Outcome = withContext(io) {
|
||||
val list = database.taskLists().entity(listId) ?: return@withContext Outcome.NoAccount
|
||||
if (list.isReadOnly) return@withContext Outcome.ReadOnly
|
||||
val url = list.href?.toHttpUrlOrNull() ?: return@withContext Outcome.NoAccount
|
||||
@@ -197,7 +227,7 @@ class RemoteListRepository @Inject constructor(
|
||||
* grades 404 and 410 that way — because otherwise a collection someone
|
||||
* removed from another client leaves a row here that nothing can get rid of.
|
||||
*/
|
||||
override suspend fun delete(listId: Long): Outcome = withContext(io) {
|
||||
suspend fun delete(listId: Long): Outcome = withContext(io) {
|
||||
val list = database.taskLists().entity(listId) ?: return@withContext Outcome.Done
|
||||
if (list.isReadOnly) return@withContext Outcome.ReadOnly
|
||||
val url = list.href?.toHttpUrlOrNull() ?: return@withContext Outcome.NoAccount
|
||||
@@ -1,52 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.data.sync
|
||||
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
|
||||
|
||||
/**
|
||||
* Task lists that live on a server: making, renaming, recolouring and deleting
|
||||
* them. The `full` flavor talks CalDAV; the `offline` one has no accounts, so
|
||||
* every list is device-only and none of this is ever reached with a real one.
|
||||
*/
|
||||
interface RemoteLists {
|
||||
|
||||
/** Why a collection write did not happen, in a form the UI can translate. */
|
||||
sealed interface Outcome {
|
||||
data object Done : Outcome
|
||||
|
||||
/** The server said no, and will say no again. */
|
||||
data class Refused(val code: Int) : Outcome
|
||||
|
||||
/** The server could not be reached. Worth another try. */
|
||||
data object Unreachable : Outcome
|
||||
|
||||
/** This account cannot make collections at all — iCloud, Posteo, Google. */
|
||||
data object Unsupported : Outcome
|
||||
|
||||
/** Ours is a read-only share; the write belongs to whoever owns it. */
|
||||
data object ReadOnly : Outcome
|
||||
|
||||
/** The account is gone, stopped, or has no credential we can decrypt. */
|
||||
data object NoAccount : Outcome
|
||||
|
||||
/**
|
||||
* The server answered something this call cannot make sense of.
|
||||
*
|
||||
* ⚠️ Not [Unreachable]. `CollectionOutcome` is one type across create,
|
||||
* update and delete, so each of them has branches the other's method
|
||||
* can return and its own cannot — and mapping those to [Unreachable]
|
||||
* told someone sitting on wifi that they were offline. Unreachable is a
|
||||
* claim about the network, and this is not one.
|
||||
*/
|
||||
data object Unexpected : Outcome
|
||||
}
|
||||
|
||||
/** The accounts a new list may be created on. */
|
||||
suspend fun creatableAccounts(): List<AccountEntity>
|
||||
|
||||
/** Makes a collection on [accountId]'s home set and a row pointing at it. */
|
||||
suspend fun create(accountId: Long, name: String, color: Int): Outcome
|
||||
|
||||
suspend fun rename(listId: Long, name: String, color: Int): Outcome
|
||||
|
||||
suspend fun delete(listId: Long): Outcome
|
||||
}
|
||||
+6
-4
@@ -14,7 +14,6 @@ import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.agendula.MainActivity
|
||||
import de.jeanlucmakiola.agendula.data.di.ChannelRefresher
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -34,7 +33,7 @@ import javax.inject.Singleton
|
||||
@Singleton
|
||||
class SyncNoticeNotifier @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : ChannelRefresher {
|
||||
) {
|
||||
|
||||
fun canPost(): Boolean {
|
||||
val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
@@ -124,7 +123,7 @@ class SyncNoticeNotifier @Inject constructor(
|
||||
* extra names the account for routing to Settings → Accounts → it.
|
||||
*/
|
||||
private fun signInIntent(accountId: Long): Intent =
|
||||
MainActivity.openIntent(context).putExtra(MainActivity.EXTRA_SIGN_IN_ACCOUNT_ID, accountId)
|
||||
MainActivity.openIntent(context).putExtra(EXTRA_SIGN_IN_ACCOUNT_ID, accountId)
|
||||
|
||||
private fun ensureSignInChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
@@ -171,13 +170,16 @@ class SyncNoticeNotifier @Inject constructor(
|
||||
}
|
||||
|
||||
/** Re-create the channel, if it exists, in the current language. */
|
||||
override fun refreshChannel() {
|
||||
fun refreshChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val manager = context.getSystemService(NotificationManager::class.java)
|
||||
if (manager.getNotificationChannel(CHANNEL_ID) != null) ensureChannel()
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** The account a sign-in notification is about, on the intent it opens. */
|
||||
const val EXTRA_SIGN_IN_ACCOUNT_ID = "de.jeanlucmakiola.agendula.extra.SIGN_IN_ACCOUNT_ID"
|
||||
|
||||
private const val CHANNEL_ID = "sync_notices"
|
||||
private const val NOTIFICATION_ID = 2
|
||||
private const val SIGN_IN_CHANNEL_ID = "account_sign_in"
|
||||
@@ -1,6 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.data.sync
|
||||
|
||||
/** Asks for an account to be synced now. A no-op in the `offline` flavor. */
|
||||
fun interface SyncRequests {
|
||||
fun syncNow(accountName: String)
|
||||
}
|
||||
+1
-5
@@ -35,11 +35,7 @@ import kotlin.time.Duration.Companion.seconds
|
||||
@Singleton
|
||||
class SyncTrigger @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : SyncRequests {
|
||||
|
||||
override fun syncNow(accountName: String) {
|
||||
enqueue(accountName)
|
||||
}
|
||||
) {
|
||||
|
||||
/**
|
||||
* Starts a sync now.
|
||||
@@ -1,6 +1,5 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import android.content.ContentProviderOperation
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
@@ -20,7 +19,6 @@ import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.agendula.domain.export.ExportTask
|
||||
import de.jeanlucmakiola.agendula.domain.normalizeTags
|
||||
import de.jeanlucmakiola.agendula.domain.recurrence.ExpansionWindow
|
||||
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander
|
||||
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
|
||||
@@ -92,14 +90,11 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
// `is_recurring` / `distance_from_current`). The by-name mapper reads
|
||||
// whatever is present and nulls the rest; the repository re-sorts, so we
|
||||
// don't depend on a provider sort column either.
|
||||
val tasks = resolver.query(uri, null, selection, args, null)
|
||||
return resolver.query(uri, null, selection, args, null)
|
||||
?.use { c ->
|
||||
val reader = CursorColumnReader(c)
|
||||
buildList { while (c.moveToNext()) add(TaskMapper.task(reader)) }
|
||||
} ?: emptyList()
|
||||
if (tasks.isEmpty()) return tasks
|
||||
val tags = categories(tasks.map { it.taskId })
|
||||
return tasks.map { task -> tags[task.taskId]?.let { task.copy(tags = it) } ?: task }
|
||||
}
|
||||
|
||||
override fun exportTasks(listId: Long): List<ExportTask> {
|
||||
@@ -107,7 +102,7 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
// table's shape varies across provider versions, and the by-name mapper
|
||||
// reads what's there.
|
||||
val uri = TasksContract.tasksUri(authority())
|
||||
val tasks = resolver.query(
|
||||
return resolver.query(
|
||||
uri,
|
||||
null,
|
||||
// _deleted marks a row awaiting a sync round-trip. It's gone as far as
|
||||
@@ -120,39 +115,21 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
val reader = CursorColumnReader(c)
|
||||
buildList { while (c.moveToNext()) add(TaskMapper.exportTask(reader)) }
|
||||
} ?: emptyList()
|
||||
if (tasks.isEmpty()) return tasks
|
||||
val tags = categories(tasks.map { it.taskId })
|
||||
return tasks.map { task -> tags[task.taskId]?.let { task.copy(tags = it) } ?: task }
|
||||
}
|
||||
|
||||
// --- writes ---------------------------------------------------------------
|
||||
|
||||
override fun insertTask(form: TaskForm): Long {
|
||||
val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id)
|
||||
// One batch, so a refused tag leaves no task behind for a retry to duplicate.
|
||||
val authority = authority()
|
||||
val ops = arrayListOf(
|
||||
ContentProviderOperation.newInsert(TasksContract.tasksUri(authority))
|
||||
.withValues(values.toContentValues())
|
||||
.build(),
|
||||
)
|
||||
normalizeTags(form.tags).forEach { tag ->
|
||||
ops += ContentProviderOperation.newInsert(TasksContract.propertiesUri(authority))
|
||||
.withValues(TaskWriteMapper.categoryValues(0, tag).toContentValues().apply { remove(Properties.TASK_ID) })
|
||||
.withValueBackReference(Properties.TASK_ID, 0)
|
||||
.build()
|
||||
}
|
||||
val results = runCatching { resolver.applyBatch(authority, ops) }
|
||||
.getOrElse { throw TaskWriteFailedException("insert task", it) }
|
||||
return results.firstOrNull()?.uri?.lastPathSegment?.toLongOrNull()
|
||||
?: throw TaskWriteFailedException("insert task: no id")
|
||||
val uri = resolver.insert(TasksContract.tasksUri(authority()), values.toContentValues())
|
||||
?: throw TaskWriteFailedException("insert task")
|
||||
return uri.lastPathSegment?.toLongOrNull() ?: throw TaskWriteFailedException("insert task: no id")
|
||||
}
|
||||
|
||||
override fun updateTask(taskId: Long, form: TaskForm) {
|
||||
val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id)
|
||||
val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
|
||||
if (rows == 0) throw TaskWriteFailedException("update task $taskId")
|
||||
setCategories(taskId, form.tags)
|
||||
}
|
||||
|
||||
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) {
|
||||
@@ -174,7 +151,6 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
(Tasks.DUE to shifted(anchors?.second, occurrence?.due, form.due))
|
||||
val rows = resolver.update(taskUri(authority(), seriesId), values.toContentValues(), null, null)
|
||||
if (rows == 0) throw TaskWriteFailedException("update series $seriesId")
|
||||
setCategories(seriesId, form.tags)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -267,28 +243,8 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
?: throw TaskWriteFailedException("update instance $taskId@$occurrenceStart: no such occurrence")
|
||||
val values = TaskWriteMapper.instanceValues(form, ZoneId.systemDefault().id)
|
||||
val uri = TasksContract.instanceUri(authority(), instanceId)
|
||||
// The fork copies the series' properties onto the override, so the tags
|
||||
// go onto the series first and are put back once the override exists.
|
||||
// Unchanged tags are inherited as they are, and the series is left alone.
|
||||
val seriesTags = categories(listOf(taskId))[taskId].orEmpty()
|
||||
val retag = normalizeTags(form.tags) != seriesTags
|
||||
if (retag) setCategories(taskId, form.tags)
|
||||
var failure: Throwable? = null
|
||||
try {
|
||||
val rows = resolver.update(uri, values.toContentValues(), null, null)
|
||||
if (rows == 0) throw TaskWriteFailedException("update instance $instanceId")
|
||||
} catch (e: Throwable) {
|
||||
failure = e
|
||||
throw e
|
||||
} finally {
|
||||
if (retag) {
|
||||
try {
|
||||
setCategories(taskId, seriesTags)
|
||||
} catch (e: Throwable) {
|
||||
failure?.addSuppressed(e) ?: throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
val rows = resolver.update(uri, values.toContentValues(), null, null)
|
||||
if (rows == 0) throw TaskWriteFailedException("update instance $instanceId")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -392,58 +348,6 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
/** Tags by task id for [taskIds], in stored order. */
|
||||
private fun categories(taskIds: Collection<Long>): Map<Long, List<String>> {
|
||||
val rows = taskIds.distinct().chunked(MAX_SQL_ARGS - 1).flatMap { chunk ->
|
||||
val selection = "${Properties.MIMETYPE} = ? AND ${Properties.TASK_ID} IN (${chunk.joinToString(",") { "?" }})"
|
||||
val args = (listOf(TasksContract.Category.MIMETYPE) + chunk.map(Long::toString)).toTypedArray()
|
||||
resolver.query(
|
||||
TasksContract.propertiesUri(authority()),
|
||||
arrayOf(Properties.TASK_ID, TasksContract.Category.NAME),
|
||||
selection,
|
||||
args,
|
||||
Properties.PROPERTY_ID,
|
||||
)?.use { c ->
|
||||
val reader = CursorColumnReader(c)
|
||||
buildList {
|
||||
while (c.moveToNext()) {
|
||||
val id = reader.getLong(Properties.TASK_ID) ?: continue
|
||||
val name = reader.getString(TasksContract.Category.NAME) ?: continue
|
||||
add(id to name)
|
||||
}
|
||||
}
|
||||
} ?: emptyList()
|
||||
}
|
||||
return rows.groupBy({ it.first }, { it.second }).mapValues { normalizeTags(it.value) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces [taskId]'s category rows in one batch, so a refused insert cannot
|
||||
* leave half the tags, and leaves them alone when nothing changed so the task
|
||||
* isn't dirtied.
|
||||
*/
|
||||
private fun setCategories(taskId: Long, tags: List<String>) {
|
||||
val wanted = normalizeTags(tags)
|
||||
if (categories(listOf(taskId))[taskId].orEmpty() == wanted) return
|
||||
val authority = authority()
|
||||
val uri = TasksContract.propertiesUri(authority)
|
||||
val ops = arrayListOf(
|
||||
ContentProviderOperation.newDelete(uri)
|
||||
.withSelection(
|
||||
"${Properties.TASK_ID} = ? AND ${Properties.MIMETYPE} = ?",
|
||||
arrayOf(taskId.toString(), TasksContract.Category.MIMETYPE),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
wanted.forEach {
|
||||
ops += ContentProviderOperation.newInsert(uri)
|
||||
.withValues(TaskWriteMapper.categoryValues(taskId, it).toContentValues())
|
||||
.build()
|
||||
}
|
||||
runCatching { resolver.applyBatch(authority, ops) }
|
||||
.getOrElse { throw TaskWriteFailedException("set categories for task $taskId", it) }
|
||||
}
|
||||
|
||||
override fun setCancelled(taskId: Long, cancelled: Boolean) {
|
||||
val rows = resolver.update(taskUri(authority(), taskId), cancelValues(cancelled).toContentValues(), null, null)
|
||||
if (rows == 0) throw TaskWriteFailedException("cancel task $taskId")
|
||||
@@ -556,6 +460,3 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
return cv
|
||||
}
|
||||
}
|
||||
|
||||
/** SQLite's default bound-argument limit on older Android releases. */
|
||||
private const val MAX_SQL_ARGS = 999
|
||||
|
||||
@@ -5,8 +5,8 @@ class ProviderUnavailableException :
|
||||
IllegalStateException("No tasks provider installed")
|
||||
|
||||
/** A ContentResolver write returned no URI or affected no rows. */
|
||||
class TaskWriteFailedException(operation: String, cause: Throwable? = null) :
|
||||
RuntimeException("Task write failed: $operation", cause)
|
||||
class TaskWriteFailedException(operation: String) :
|
||||
RuntimeException("Task write failed: $operation")
|
||||
|
||||
/**
|
||||
* The task changed (e.g. a DAVx5 sync or another app) since the edit form loaded
|
||||
|
||||
@@ -113,12 +113,6 @@ object TaskWriteMapper {
|
||||
Alarm.ALARM_TYPE to Alarm.TYPE_MESSAGE,
|
||||
)
|
||||
|
||||
fun categoryValues(taskId: Long, name: String): Map<String, Any?> = mapOf(
|
||||
Properties.TASK_ID to taskId,
|
||||
Properties.MIMETYPE to TasksContract.Category.MIMETYPE,
|
||||
TasksContract.Category.NAME to name,
|
||||
)
|
||||
|
||||
/** The user-owned columns of a list — what an edit is allowed to change. */
|
||||
fun listValues(name: String, color: Int): Map<String, Any?> = mapOf(
|
||||
Lists.NAME to name.trim(),
|
||||
|
||||
@@ -154,18 +154,6 @@ object TasksContract {
|
||||
const val TYPE_MESSAGE = 1
|
||||
}
|
||||
|
||||
/**
|
||||
* A category property row — one tag. DAVx5 maps these to and from
|
||||
* `CATEGORIES`. Inserting with only [NAME] is enough: the provider looks the
|
||||
* category up in the task's account, or creates it, and fills in `data0`.
|
||||
*/
|
||||
object Category {
|
||||
const val MIMETYPE = "vnd.android.cursor.item/category"
|
||||
|
||||
/** `data1` — the tag as spelled. */
|
||||
const val NAME = "data1"
|
||||
}
|
||||
|
||||
fun propertiesUri(authority: String): Uri = "content://$authority/${Properties.PATH}".toUri()
|
||||
|
||||
// --- status values (TaskColumns.STATUS_*) --------------------------------
|
||||
|
||||
@@ -63,10 +63,6 @@ class TasksRepositoryImpl @Inject constructor(
|
||||
// parent's subtask-progress reflects ALL its children — completed ones
|
||||
// included — even on smart lists that hide completed tasks themselves.
|
||||
is TaskFilter.Smart -> TaskQuery(includeCompleted = true)
|
||||
// Same over-read as Smart: TaskQuery.listId only narrows to one list, so
|
||||
// several lists are read in full and membership is filtered in-memory.
|
||||
is TaskFilter.OfLists -> TaskQuery(includeCompleted = true)
|
||||
is TaskFilter.Tagged -> TaskQuery(includeCompleted = true)
|
||||
}
|
||||
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
|
||||
val all = SeriesCollapse.visible(dataSource.tasks(query))
|
||||
@@ -113,7 +109,6 @@ class TasksRepositoryImpl @Inject constructor(
|
||||
parentId = parentId,
|
||||
location = task.location,
|
||||
url = task.url,
|
||||
tags = task.tags,
|
||||
),
|
||||
)
|
||||
val reminders = dataSource.reminders()[task.taskId].orEmpty()
|
||||
|
||||
@@ -9,7 +9,6 @@ import de.jeanlucmakiola.agendula.domain.ical.ICalParser
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalProperty
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
|
||||
import de.jeanlucmakiola.agendula.domain.normalizeTags
|
||||
import java.time.ZoneId
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
@@ -82,8 +81,6 @@ object VTodoMapper {
|
||||
// Parameterised copies stay in the residue verbatim; authoring the
|
||||
// column beside them would emit the same exclusions twice.
|
||||
"RDATE", "EXDATE",
|
||||
// Several lines, or one the column would not re-serialise exactly.
|
||||
"CATEGORIES",
|
||||
)
|
||||
|
||||
/** What a VTODO yields. Row identity ([TaskEntity.id], `listId`) is the caller's. */
|
||||
@@ -203,9 +200,6 @@ object VTodoMapper {
|
||||
CLASS_NAMES.indexOf(classProperty?.value?.trim()?.uppercase()).takeIf { it >= 0 },
|
||||
)
|
||||
|
||||
val (categories, categoriesProperty) = readCategories(vtodo)
|
||||
claim(categoriesProperty)
|
||||
|
||||
val sequenceProperty = vtodo.property("SEQUENCE")
|
||||
val sequence = take(
|
||||
sequenceProperty,
|
||||
@@ -231,7 +225,6 @@ object VTodoMapper {
|
||||
title = take(vtodo.property("SUMMARY"), vtodo.property("SUMMARY")?.text()),
|
||||
description = take(vtodo.property("DESCRIPTION"), vtodo.property("DESCRIPTION")?.text()),
|
||||
location = take(vtodo.property("LOCATION"), vtodo.property("LOCATION")?.text()),
|
||||
categories = categories,
|
||||
// URI, not TEXT — escaping it would corrupt a query string.
|
||||
url = take(vtodo.property("URL"), vtodo.property("URL")?.value?.trim()),
|
||||
status = status,
|
||||
@@ -291,9 +284,8 @@ object VTodoMapper {
|
||||
now: Instant = Clock.System.now(),
|
||||
): ICalComponent {
|
||||
val residue = parseResidue(entity.unknownProperties)
|
||||
val residueCategories = normalizeTags(residue.properties("CATEGORIES").flatMap(::categoryValues))
|
||||
val keptResidue =
|
||||
residue.properties.filterNot { contradictsResidue(it, residue, residueCategories, entity, parentUid) }
|
||||
residue.properties.filterNot { contradictsResidue(it, residue, entity, parentUid) }
|
||||
val suppressed = keptResidue
|
||||
.map { it.name.uppercase() }
|
||||
.filterTo(mutableSetOf()) { it in SUPPRESSED_BY_RESIDUE }
|
||||
@@ -315,7 +307,6 @@ object VTodoMapper {
|
||||
add("DESCRIPTION", entity.description?.let(ICalValues::escapeText))
|
||||
add("LOCATION", entity.location?.let(ICalValues::escapeText))
|
||||
add("URL", entity.url)
|
||||
add("CATEGORIES", normalizeTags(entity.categories).takeIf { it.isNotEmpty() }?.let(ICalValues::formatTextList))
|
||||
|
||||
add("STATUS", entity.status.toICalName())
|
||||
add("PERCENT-COMPLETE", entity.percentComplete?.toString())
|
||||
@@ -395,7 +386,6 @@ object VTodoMapper {
|
||||
private fun contradictsResidue(
|
||||
property: ICalProperty,
|
||||
residue: ICalComponent,
|
||||
residueCategories: List<String>,
|
||||
entity: TaskEntity,
|
||||
parentUid: String?,
|
||||
): Boolean {
|
||||
@@ -443,7 +433,6 @@ object VTodoMapper {
|
||||
"PRIORITY" -> entity.priority != PRIORITY_NONE
|
||||
"PERCENT-COMPLETE" -> entity.percentComplete != null
|
||||
"CLASS" -> entity.classification != null
|
||||
"CATEGORIES" -> entity.categories != residueCategories
|
||||
// Not SEQUENCE: it is the organiser's counter and never ours to bump,
|
||||
// so the column stays at its fallback and the residue always wins.
|
||||
else -> false
|
||||
@@ -486,23 +475,6 @@ object VTodoMapper {
|
||||
|
||||
// -------------------------------------------------------------- helpers
|
||||
|
||||
/**
|
||||
* Every `CATEGORIES` copy's values, merged, and the one line to claim — only
|
||||
* when it is the sole, parameterless copy and the column re-serialises it
|
||||
* byte for byte. Anything else is read for display and stays in the residue.
|
||||
*/
|
||||
private fun readCategories(vtodo: ICalComponent): Pair<List<String>, ICalProperty?> {
|
||||
val copies = vtodo.properties("CATEGORIES")
|
||||
val values = normalizeTags(copies.flatMap(::categoryValues))
|
||||
val claimable = copies.singleOrNull()?.takeIf {
|
||||
it.params.isEmpty() && values.isNotEmpty() && ICalValues.formatTextList(values) == it.value
|
||||
}
|
||||
return values to claimable
|
||||
}
|
||||
|
||||
private fun categoryValues(property: ICalProperty): List<String> =
|
||||
normalizeTags(ICalValues.parseTextList(property.value))
|
||||
|
||||
private val CLASS_NAMES = listOf("PUBLIC", "PRIVATE", "CONFIDENTIAL")
|
||||
|
||||
private class TimeRead(
|
||||
|
||||
@@ -15,7 +15,6 @@ import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
|
||||
import de.jeanlucmakiola.agendula.domain.normalizeTags
|
||||
import de.jeanlucmakiola.agendula.domain.statusFromInt
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -222,17 +221,12 @@ class OneShotImport @Inject constructor(
|
||||
}
|
||||
|
||||
val alarms = mutableListOf<LegacyAlarm>()
|
||||
val categories = mutableMapOf<Long, MutableList<String>>()
|
||||
db.rawQuery("SELECT task_id, mimetype, data0, data1, data2 FROM Properties ORDER BY property_id", null)
|
||||
db.rawQuery("SELECT task_id, mimetype, data0, data1, data2 FROM Properties", null)
|
||||
.use { cursor ->
|
||||
val r = CursorColumnReader(cursor)
|
||||
while (cursor.moveToNext()) {
|
||||
val taskId = r.getLong("task_id") ?: continue
|
||||
if (r.getString("mimetype") == CATEGORY_MIMETYPE) {
|
||||
r.getString("data1")?.let { categories.getOrPut(taskId, ::mutableListOf) += it }
|
||||
continue
|
||||
}
|
||||
if (r.getString("mimetype") != ALARM_MIMETYPE) continue
|
||||
val taskId = r.getLong("task_id") ?: continue
|
||||
val minutes = r.getString("data0")?.trim()?.toIntOrNull() ?: continue
|
||||
alarms += LegacyAlarm(
|
||||
taskId = taskId,
|
||||
@@ -247,7 +241,7 @@ class OneShotImport @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
return LegacySnapshot(lists, rows, alarms, categories.mapValues { normalizeTags(it.value) })
|
||||
return LegacySnapshot(lists, rows, alarms)
|
||||
}
|
||||
|
||||
// --- writing into Room ----------------------------------------------------
|
||||
@@ -282,12 +276,7 @@ class OneShotImport @Inject constructor(
|
||||
// the unique index; a fresh uid costs the row nothing it still has.
|
||||
val uid = row.entity.uid.takeIf { seen.add(Triple(listId, it, recurrenceId)) }
|
||||
?: UUID.randomUUID().toString()
|
||||
val entity = row.entity.copy(
|
||||
listId = listId,
|
||||
uid = uid,
|
||||
recurrenceId = recurrenceId,
|
||||
categories = snapshot.categories[row.id].orEmpty(),
|
||||
)
|
||||
val entity = row.entity.copy(listId = listId, uid = uid, recurrenceId = recurrenceId)
|
||||
val newId = taskDao.insert(entity)
|
||||
taskIds[row.id] = newId
|
||||
inserted += row to entity.copy(id = newId)
|
||||
@@ -390,7 +379,6 @@ class OneShotImport @Inject constructor(
|
||||
const val ARCHIVE_NAME = "tasks.db.imported"
|
||||
|
||||
private const val ALARM_MIMETYPE = "vnd.android.cursor.item/alarm"
|
||||
private const val CATEGORY_MIMETYPE = "vnd.android.cursor.item/category"
|
||||
private const val REFERENCE_START = "2"
|
||||
private val SIDECARS = listOf("-journal", "-wal", "-shm")
|
||||
private val IMPORT_DONE = booleanPreferencesKey("legacy_import_done")
|
||||
@@ -403,8 +391,6 @@ private class LegacySnapshot(
|
||||
val lists: List<LegacyList>,
|
||||
val tasks: List<LegacyTaskRow>,
|
||||
val alarms: List<LegacyAlarm>,
|
||||
/** Tags by legacy task `_id`. */
|
||||
val categories: Map<Long, List<String>>,
|
||||
)
|
||||
|
||||
private class LegacyList(val id: Long, val entity: TaskListEntity)
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalComponent
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalParser
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalProperty
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
|
||||
|
||||
/**
|
||||
* Lifts `CATEGORIES` out of a residue written before v2 claimed it.
|
||||
*
|
||||
* ⚠️ Frozen with [TasksDatabase.MIGRATION_1_2]. The claim rule is copied here
|
||||
* rather than shared with the mapper, so a later change to what the mapper
|
||||
* claims cannot change what this migration produced for an earlier upgrader.
|
||||
*/
|
||||
internal object CategoriesLift {
|
||||
|
||||
/**
|
||||
* Every copy's values merged, and the residue with the one claimed line
|
||||
* removed — or `null` for the residue when nothing was claimed, so the caller
|
||||
* leaves the stored one alone.
|
||||
*/
|
||||
data class Result(val categories: List<String>, val residue: String?, val claimed: Boolean)
|
||||
|
||||
fun lift(residue: String?): Result {
|
||||
val component = parse(residue) ?: return Result(emptyList(), residue, claimed = false)
|
||||
val copies = component.properties.filter { it.name.equals("CATEGORIES", ignoreCase = true) }
|
||||
val values = copies.flatMap(::values)
|
||||
val claim = copies.singleOrNull()?.takeIf {
|
||||
it.params.isEmpty() && values.isNotEmpty() && ICalValues.formatTextList(values) == it.value
|
||||
} ?: return Result(values, residue, claimed = false)
|
||||
val rest = ICalSerializer.serializeProperties(component.properties.filterNot { it === claim }) +
|
||||
ICalSerializer.serializeAll(component.components)
|
||||
return Result(values, rest.ifEmpty { null }, claimed = true)
|
||||
}
|
||||
|
||||
private fun values(property: ICalProperty): List<String> =
|
||||
ICalValues.parseTextList(property.value).map(String::trim).filter(String::isNotEmpty).distinct()
|
||||
|
||||
private fun parse(text: String?): ICalComponent? {
|
||||
if (text.isNullOrEmpty()) return null
|
||||
return runCatching { ICalParser.parse("BEGIN:VTODO\r\n$text\r\nEND:VTODO\r\n") }.getOrNull()
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,6 @@ package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
|
||||
import de.jeanlucmakiola.agendula.domain.normalizeTags
|
||||
import de.jeanlucmakiola.agendula.domain.statusFromInt
|
||||
import de.jeanlucmakiola.agendula.domain.toInt
|
||||
import kotlin.time.Instant
|
||||
@@ -38,17 +36,3 @@ object Converters {
|
||||
fun alarmReferenceFrom(value: String): AlarmReference =
|
||||
runCatching { AlarmReference.valueOf(value) }.getOrDefault(AlarmReference.DUE)
|
||||
}
|
||||
|
||||
/**
|
||||
* [TaskEntity.categories] stored as a `CATEGORIES` value, so escaping has one
|
||||
* home; empty when there are none. Scoped to that field rather than the
|
||||
* database, so no other `List<String>` picks up iCalendar escaping by accident.
|
||||
*/
|
||||
object CategoriesConverter {
|
||||
@TypeConverter
|
||||
fun toText(value: List<String>): String = ICalValues.formatTextList(normalizeTags(value))
|
||||
|
||||
@TypeConverter
|
||||
fun fromText(value: String): List<String> =
|
||||
if (value.isEmpty()) emptyList() else normalizeTags(ICalValues.parseTextList(value))
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.TypeConverters
|
||||
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import kotlin.time.Instant
|
||||
@@ -72,7 +71,7 @@ data class TaskListEntity(
|
||||
*
|
||||
* ⚠️ It was set by a rename and read by nobody. A collection's name and
|
||||
* colour are now written server-first by
|
||||
* [de.jeanlucmakiola.agendula.data.sync.RemoteLists], so there is
|
||||
* [de.jeanlucmakiola.agendula.data.sync.RemoteListRepository], so there is
|
||||
* no local edit left waiting to be pushed — and a flag that means "owed to
|
||||
* the server" while nothing ever pays it is worse than no flag at all.
|
||||
*/
|
||||
@@ -144,10 +143,6 @@ data class TaskEntity(
|
||||
@ColumnInfo(name = "url") val url: String? = null,
|
||||
/** ARGB override for the list colour. */
|
||||
@ColumnInfo(name = "color") val color: Int? = null,
|
||||
/** RFC 5545 `CATEGORIES` — the tags — in the spelling each task carries them. */
|
||||
@ColumnInfo(name = "categories", defaultValue = "")
|
||||
@field:TypeConverters(CategoriesConverter::class)
|
||||
val categories: List<String> = emptyList(),
|
||||
|
||||
// state
|
||||
@ColumnInfo(name = "status", defaultValue = "0") val status: TaskStatus = TaskStatus.NEEDS_ACTION,
|
||||
|
||||
@@ -49,7 +49,6 @@ object RoomTaskMapper {
|
||||
description = row.task.description,
|
||||
location = row.task.location,
|
||||
url = row.task.url,
|
||||
tags = row.task.categories,
|
||||
priority = priorityFromICal(row.task.priority),
|
||||
status = row.task.status,
|
||||
percentComplete = row.task.percentComplete,
|
||||
@@ -80,7 +79,6 @@ object RoomTaskMapper {
|
||||
description = task.description,
|
||||
location = task.location,
|
||||
url = task.url,
|
||||
tags = task.categories,
|
||||
priority = priorityFromICal(task.priority),
|
||||
status = task.status,
|
||||
percentComplete = task.percentComplete,
|
||||
|
||||
@@ -520,7 +520,7 @@ class RoomTasksDataSource @Inject constructor(
|
||||
|
||||
/**
|
||||
* ⚠️ Device-only lists. An account-backed collection is renamed through
|
||||
* [de.jeanlucmakiola.agendula.data.sync.RemoteLists], which sends
|
||||
* [de.jeanlucmakiola.agendula.data.sync.RemoteListRepository], which sends
|
||||
* the PROPPATCH first and writes here only once the server has taken it.
|
||||
*
|
||||
* This used to set `is_dirty` for an account list instead, on the theory
|
||||
|
||||
@@ -3,7 +3,6 @@ package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
|
||||
import de.jeanlucmakiola.agendula.domain.normalizeTags
|
||||
import de.jeanlucmakiola.agendula.domain.toICal
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
@@ -51,7 +50,6 @@ object TaskFormWriter {
|
||||
description = form.description?.trim()?.ifBlank { null },
|
||||
location = form.location?.trim()?.ifBlank { null },
|
||||
url = form.url?.trim()?.ifBlank { null },
|
||||
categories = normalizeTags(form.tags),
|
||||
priority = form.priority.toICal(),
|
||||
percentComplete = percent,
|
||||
status = statusFor(percent, current.status),
|
||||
|
||||
@@ -3,8 +3,6 @@ package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
/**
|
||||
* Agendula's own task store. Four tables, designed from
|
||||
@@ -20,7 +18,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
TaskEntity::class,
|
||||
TaskAlarmEntity::class,
|
||||
],
|
||||
version = 2,
|
||||
version = 1,
|
||||
exportSchema = true,
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
@@ -32,36 +30,5 @@ abstract class TasksDatabase : RoomDatabase() {
|
||||
|
||||
companion object {
|
||||
const val NAME = "agendula-tasks.db"
|
||||
|
||||
/**
|
||||
* v2 adds `categories`. Until then `CATEGORIES` lived in the residue, and
|
||||
* an unchanged task is never downloaded again, so the values are lifted
|
||||
* out here. Not marked dirty: nothing changed on the server's side.
|
||||
*/
|
||||
val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE tasks ADD COLUMN categories TEXT NOT NULL DEFAULT ''")
|
||||
val lifted = mutableListOf<Pair<Long, CategoriesLift.Result>>()
|
||||
db.query(
|
||||
"SELECT id, unknown_properties FROM tasks WHERE unknown_properties LIKE '%CATEGORIES%'",
|
||||
).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val result = CategoriesLift.lift(cursor.getString(1))
|
||||
if (result.categories.isNotEmpty()) lifted += cursor.getLong(0) to result
|
||||
}
|
||||
}
|
||||
lifted.forEach { (id, result) ->
|
||||
val categories = CategoriesConverter.toText(result.categories)
|
||||
if (result.claimed) {
|
||||
db.execSQL(
|
||||
"UPDATE tasks SET categories = ?, unknown_properties = ? WHERE id = ?",
|
||||
arrayOf<Any?>(categories, result.residue, id),
|
||||
)
|
||||
} else {
|
||||
db.execSQL("UPDATE tasks SET categories = ? WHERE id = ?", arrayOf<Any?>(categories, id))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.agendula.domain.export.ExportTask
|
||||
import de.jeanlucmakiola.agendula.domain.normalizeTags
|
||||
import de.jeanlucmakiola.agendula.domain.toICal
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -214,7 +213,6 @@ class ExternalImport @Inject constructor(
|
||||
description = description,
|
||||
location = location,
|
||||
url = url,
|
||||
categories = normalizeTags(tags),
|
||||
status = status,
|
||||
percentComplete = percentComplete,
|
||||
completedAt = completedAt,
|
||||
|
||||
@@ -52,8 +52,6 @@ data class Task(
|
||||
val description: String?,
|
||||
val location: String?,
|
||||
val url: String?,
|
||||
/** `CATEGORIES`, as the task spells them. */
|
||||
val tags: List<String> = emptyList(),
|
||||
val priority: Priority,
|
||||
val status: TaskStatus,
|
||||
val percentComplete: Int?,
|
||||
@@ -163,10 +161,3 @@ fun TaskStatus.toInt(): Int = when (this) {
|
||||
TaskStatus.COMPLETED -> ICalStatus.COMPLETED
|
||||
TaskStatus.CANCELLED -> ICalStatus.CANCELLED
|
||||
}
|
||||
|
||||
/** Tags as every writer stores them: trimmed, no blanks, each spelling once, order kept. */
|
||||
fun normalizeTags(tags: List<String>): List<String> = tags.map(String::trim).filter(String::isNotEmpty).distinct()
|
||||
|
||||
/** Every tag on [tasks], each spelling once, alphabetically ignoring case. */
|
||||
fun tagsInUse(tasks: Iterable<Task>): List<String> =
|
||||
tasks.flatMap { it.tags }.distinct().sortedWith(String.CASE_INSENSITIVE_ORDER)
|
||||
|
||||
@@ -5,14 +5,10 @@ import kotlin.time.Instant
|
||||
/** The built-in "smart" lists, computed from due dates rather than membership. */
|
||||
enum class SmartList { ALL, TODAY, UPCOMING, OVERDUE, NO_DATE, COMPLETED }
|
||||
|
||||
/** What a task screen is showing: one real list, a smart list, or a tag. */
|
||||
/** What a task screen is showing: one real list, or a smart list. */
|
||||
sealed interface TaskFilter {
|
||||
data class OfList(val listId: Long) : TaskFilter
|
||||
/** Several lists shown together, e.g. one widget covering more than one list. */
|
||||
data class OfLists(val listIds: Set<Long>) : TaskFilter
|
||||
data class Smart(val list: SmartList) : TaskFilter
|
||||
/** Every task carrying [tag], open and completed, across all lists. */
|
||||
data class Tagged(val tag: String) : TaskFilter
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,14 +21,9 @@ object TaskFiltering {
|
||||
fun matches(task: Task, filter: TaskFilter, todayStart: Instant, todayEnd: Instant): Boolean =
|
||||
when (filter) {
|
||||
is TaskFilter.OfList -> task.listId == filter.listId
|
||||
is TaskFilter.OfLists -> task.listId in filter.listIds
|
||||
is TaskFilter.Smart -> matchesSmart(task, filter.list, todayStart, todayEnd)
|
||||
is TaskFilter.Tagged -> filter.tag in task.tags
|
||||
}
|
||||
|
||||
/** Whether [task] carries every one of [tags]; an empty set matches everything. */
|
||||
fun hasAllTags(task: Task, tags: Set<String>): Boolean = task.tags.containsAll(tags)
|
||||
|
||||
private fun matchesSmart(task: Task, list: SmartList, todayStart: Instant, todayEnd: Instant): Boolean {
|
||||
val due = task.due
|
||||
return when (list) {
|
||||
|
||||
@@ -22,7 +22,6 @@ data class TaskForm(
|
||||
val reminders: List<Int> = emptyList(),
|
||||
val location: String? = null,
|
||||
val url: String? = null,
|
||||
val tags: List<String> = emptyList(),
|
||||
/** The series' `RRULE` value; `null` = does not repeat. Ignored for a single-occurrence edit. */
|
||||
val rrule: String? = null,
|
||||
) {
|
||||
@@ -45,7 +44,7 @@ enum class TaskFormError { BLANK_TITLE, NO_LIST, DUE_BEFORE_START, REMINDER_WITH
|
||||
* a setting; the rest unfold on demand. Declaring these as an enum keeps the
|
||||
* disclosure generic, so adding a field later is one entry plus its card.
|
||||
*/
|
||||
enum class TaskFormField { Description, Recurrence, Priority, Progress, Parent, Reminder, Location, Url, Tags }
|
||||
enum class TaskFormField { Description, Recurrence, Priority, Progress, Parent, Reminder, Location, Url }
|
||||
|
||||
/** The optional fields that already carry a value — auto-revealed when editing. */
|
||||
fun TaskForm.populatedFields(): Set<TaskFormField> = buildSet {
|
||||
@@ -57,5 +56,4 @@ fun TaskForm.populatedFields(): Set<TaskFormField> = buildSet {
|
||||
if (rrule != null) add(TaskFormField.Recurrence)
|
||||
if (!location.isNullOrBlank()) add(TaskFormField.Location)
|
||||
if (!url.isNullOrBlank()) add(TaskFormField.Url)
|
||||
if (tags.isNotEmpty()) add(TaskFormField.Tags)
|
||||
}
|
||||
|
||||
@@ -42,7 +42,6 @@ data class ExportTask(
|
||||
val rdate: String?,
|
||||
/** `tasks._id` of the parent, for `RELATED-TO;RELTYPE=PARENT`. */
|
||||
val parentId: Long?,
|
||||
val tags: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
/** A task list and everything in it, ready to become one `.ics` document. */
|
||||
|
||||
@@ -88,7 +88,6 @@ object ICalendarWriter {
|
||||
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.tags.takeIf { it.isNotEmpty() }?.let { rawProperty("CATEGORIES", ICalValues.formatTextList(it)) }
|
||||
|
||||
task.start?.let { dateProperty("DTSTART", it, task.isAllDay) }
|
||||
task.due?.let { dateProperty("DUE", it, task.isAllDay) }
|
||||
|
||||
@@ -49,37 +49,6 @@ object ICalValues {
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* A multi-valued TEXT property (`CATEGORIES`, §3.8.1.2): split on the
|
||||
* commas that are not escaped, then unescape each value.
|
||||
*/
|
||||
fun parseTextList(value: String): List<String> {
|
||||
val values = mutableListOf<String>()
|
||||
val current = StringBuilder()
|
||||
var i = 0
|
||||
while (i < value.length) {
|
||||
val c = value[i]
|
||||
when {
|
||||
c == '\\' && i + 1 < value.length -> {
|
||||
current.append(c).append(value[i + 1])
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
c == ',' -> {
|
||||
values += unescapeText(current.toString())
|
||||
current.clear()
|
||||
}
|
||||
else -> current.append(c)
|
||||
}
|
||||
i++
|
||||
}
|
||||
values += unescapeText(current.toString())
|
||||
return values
|
||||
}
|
||||
|
||||
/** The inverse of [parseTextList]. */
|
||||
fun formatTextList(values: List<String>): String = values.joinToString(",", transform = ::escapeText)
|
||||
|
||||
/** How a DATE / DATE-TIME property was written, and whether we can reproduce it. */
|
||||
sealed interface TimeValue {
|
||||
/** `VALUE=DATE` — date-only. */
|
||||
|
||||
@@ -9,7 +9,6 @@ import androidx.compose.material.icons.rounded.LocationOn
|
||||
import androidx.compose.material.icons.rounded.Notifications
|
||||
import androidx.compose.material.icons.rounded.Percent
|
||||
import androidx.compose.material.icons.rounded.Repeat
|
||||
import androidx.compose.material.icons.rounded.Sell
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||
@@ -24,7 +23,6 @@ fun TaskFormField.labelRes(): Int = when (this) {
|
||||
TaskFormField.Reminder -> R.string.edit_reminder_label
|
||||
TaskFormField.Location -> R.string.edit_location_label
|
||||
TaskFormField.Url -> R.string.edit_url_label
|
||||
TaskFormField.Tags -> R.string.edit_tags_label
|
||||
}
|
||||
|
||||
fun TaskFormField.icon(): ImageVector = when (this) {
|
||||
@@ -36,5 +34,4 @@ fun TaskFormField.icon(): ImageVector = when (this) {
|
||||
TaskFormField.Reminder -> Icons.Rounded.Notifications
|
||||
TaskFormField.Location -> Icons.Rounded.LocationOn
|
||||
TaskFormField.Url -> Icons.Rounded.Link
|
||||
TaskFormField.Tags -> Icons.Rounded.Sell
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user