18 Commits
Author SHA1 Message Date
makiolaj ee0250faf9 chore: 1.1.0-beta.3
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Successful in 11m51s
2026-10-09 12:21:05 +02:00
Jean-Luc Makiolaandmakiolaj aa2aade930 feat: tags (#35) (#46)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped
Adds tags, stored as the standard `CATEGORIES` property so they stay compatible with Nextcloud Tasks, tasks.org and DAVx⁵.

## What changed

- **Storage and sync:** tags are a new `categories` column (database v2). The migration lifts `CATEGORIES` out of the stored unknown properties for tasks that were synced before, so existing tags show up without a re-download. A line Agendula can't write back exactly as it came (e.g. a duplicated value, or the property split across several lines) stays where it was and still syncs byte for byte.
- **Provider mode (OpenTasks / tasks.org):** tags are read and written as the provider's category properties. Writes are a single batch, so a refused tag can't leave half the tags behind or create a task twice.
- **Imports:** the copy from OpenTasks/tasks.org and the legacy one-shot import both bring tags across.
- **Editor:** a new optional Tags field. Type a tag and press Done or type a comma; tags already in use are suggested while typing, and text left in the field is still saved.
- **Display:** tags show as pills on the detail screen and in each row's info line in task lists.
- **Using tags:** the home screen has a Tags section listing every tag on an open task with its count. Tapping one opens that tag's tasks across all lists, and "New task" there starts with the tag set. Every task list's sort button is now a "Sort and filter" picker, where you can tick one or more tags to narrow the list.

## Also in here

- **Keyboard:** a focused input now scrolls fully above the keyboard, not just its text line. This uses floret-kit's new `revealOnFocus` (jlmakiola/floret-kit#12), and the pointer moves to floret-kit main.
- **Edit screen:** once the title scrolls out of view, it shows in the top bar.
- **main merged in:** the branch brings main's #40 and #44 (floret-kit on its shared main, Gradle 9.8), which `release/v1.1.0` doesn't have yet.

## Where this differs from the issue

- **Filtering by several tags** shows tasks that carry *all* of them, since ticking more tags is meant to narrow the list.
- **Tags are case-sensitive:** "Work" and "work" are two tags, matching how they are stored and synced.
- **The overview** lists tags on open tasks only, the same set the editor suggests.
- **Widgets** can't be set to a tag yet.

Closes #35

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/46
2026-10-09 12:20:21 +02:00
Jean-Luc Makiolaandmakiolaj 89775e44a7 chore: merge main into release/v1.1.0 (#42)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Successful in 26m27s
Brings the exact tag checks from #40 into the release branch. beta.yaml runs from the branch's own files, and the old prefix-matching lookup treated v1.1.0-beta.1 as a shipped v1.1.0, so the 1.1.0-beta.2 run was refused.

The one conflict was in publish_codeberg_release.sh: kept #40's stricter upload handling plus the offline APK assets from #41. scripts/release_gate.sh now reports cut=true for 1.1.0-beta.2.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/42
2026-10-06 19:19:06 +02:00
Jean-Luc Makiola c0eb03e5df Merge pull request 'feat: offline flavor without sync or network access (#39)' (#41) from feat/39-offline-flavor into release/v1.1.0
Beta — Codeberg pre-release / detect (push) Failing after 6s
Beta — Codeberg pre-release / beta (push) Skipped
2026-10-06 19:14:57 +02:00
makiolaj 7bfda26de9 chore: 1.1.0-beta.2 2026-10-06 19:11:50 +02:00
makiolaj f65aed4fe2 ci: install JetBrains 21 directly instead of fetching it via foojay 2026-10-06 19:00:48 +02:00
makiolaj 84bb1116ec feat: offline flavor without sync or network access (#39) 2026-10-06 18:34:33 +02:00
makiolaj 7fde7ae290 fix: restore the floret glance dependency lost in the rebase
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Successful in 10m34s
2026-10-05 18:58:03 +02:00
makiolaj 5d273e05c7 chore: 1.1.0-beta.1
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Failing after 2m31s
2026-10-05 18:51:20 +02:00
makiolaj 59582b3641 docs(release): keep CHANGELOG entries under Unreleased while betas go out 2026-10-05 18:49:31 +02:00
makiolaj 3540d2fb7c chore: release 1.1.0 2026-10-05 18:49:15 +02:00
makiolaj 3812e950ca fix: remove double inset on the list editor's smart-lists row 2026-10-05 18:49:08 +02:00
makiolaj c6d62c36f4 fix(widget): regenerate picker previews to match the final widget designs 2026-10-05 18:49:08 +02:00
makiolaj a4fbbf43c6 feat: allow editing synced lists, not just device-only ones
The edit pencil was gated to local lists even though rename/delete already
worked end to end for synced collections. Read-only shares keep the icon
but show it disabled, with a hint on tap explaining why.

Refs #29
2026-10-05 18:49:08 +02:00
makiolaj d81e99084e fix: remove double inset on the list editor's Where row
Refs #30
2026-10-05 18:49:08 +02:00
Jean-Luc Makiolaandmakiolaj 9a87837917 feat(widget): widget redesign and new Today/Up next/Lists/Week widgets (#33)
Reworks the home-screen widgets on top of #32.

- Tasks widget: agenda-style rows, a row-colour setting, an "All caught up" empty state
- Drops "At a glance" (it duplicated the Tasks widget) for four new widgets: Today (progress ring + today's tasks), Up next (the nearest due task, optionally from one list), Lists (adaptive shortcut grid), Week (day strip with counts)
- Ticking a task in any widget shows a done frame before the row goes away
- Shared Glance primitives live in floret-kit (`feat/glance-module`): progress ring, tiles, check button, header action pill, expressive shapes, done state

Today and Up next don't offer the transparent background, since their content isn't in containers and is unreadable on the wallpaper. No issue behind this one; it came out of reviewing the widgets on-device.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/33
2026-10-05 18:49:08 +02:00
Jean-Luc Makiolaandmakiolaj 9630bd104d feat: let a widget show tasks from several lists (#32)
Widget config now multi-selects real lists (smart lists stay
single-select, mutually exclusive) and offers an optional "group by
list" sort that heads each list's tasks with its name.

Closes #25

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/32
2026-10-05 18:49:08 +02:00
Jean-Luc Makiolaandmakiolaj 7c3b62eb0d feat: setting to default a new task's start to today (#31)
A new Settings → Task form toggle, off by default, pre-fills a fresh
task's start with the current moment instead of leaving it blank.

Closes #27

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/31
2026-10-05 18:49:08 +02:00
207 changed files with 5814 additions and 1173 deletions
+2 -2
View File
@@ -26,14 +26,14 @@ Which of these ran green, and anything you exercised by hand. On-device notes
are especially useful for UI changes, and for anything touching the provider are especially useful for UI changes, and for anything touching the provider
read/write paths (OpenTasks / tasks.org installed). read/write paths (OpenTasks / tasks.org installed).
./gradlew lintDebug :app:testDebugUnitTest :app:assembleDebug ./gradlew lintFullDebug lintOfflineDebug :app:testFullDebugUnitTest :app:testOfflineDebugUnitTest :app:assembleDebug
python3 scripts/check_translations.py python3 scripts/check_translations.py
--> -->
### Checklist ### Checklist
- [ ] `./gradlew lintDebug :app:testDebugUnitTest :app:assembleDebug` passes locally - [ ] `./gradlew lintFullDebug lintOfflineDebug :app:testFullDebugUnitTest :app:testOfflineDebugUnitTest :app:assembleDebug` passes locally
- [ ] New domain logic comes with JVM unit tests under `app/src/test/` - [ ] New domain logic comes with JVM unit tests under `app/src/test/`
- [ ] Provider details stay inside `data/tasks/` - [ ] Provider details stay inside `data/tasks/`
- [ ] No `values-*/strings.xml` touched (Weblate owns those; new English strings in `values/` are fine) - [ ] No `values-*/strings.xml` touched (Weblate owns those; new English strings in `values/` are fine)
+8 -6
View File
@@ -127,10 +127,12 @@ jobs:
- name: Setup Java - name: Setup Java
if: steps.scope.outputs.code == 'true' if: steps.scope.outputs.code == 'true'
# JetBrains 21 is what gradle-daemon-jvm.properties asks for; installing it
# here keeps Gradle from downloading it via foojay.
uses: actions/setup-java@v4 uses: actions/setup-java@v4
with: with:
distribution: 'zulu' distribution: 'jetbrains'
java-version: '17' java-version: '21'
# Fully qualified on purpose. Codeberg resolves bare `uses:` refs against # Fully qualified on purpose. Codeberg resolves bare `uses:` refs against
# data.forgejo.org, Forgejo's own action mirror — actions/checkout, # data.forgejo.org, Forgejo's own action mirror — actions/checkout,
@@ -178,18 +180,18 @@ jobs:
# No --no-daemon: the daemon lives only as long as this job container # No --no-daemon: the daemon lives only as long as this job container
# and lets the following steps skip JVM startup + reconfiguration. # and lets the following steps skip JVM startup + reconfiguration.
- name: Lint (debug variant only) - name: Lint (debug variants only)
if: steps.scope.outputs.code == 'true' if: steps.scope.outputs.code == 'true'
run: ./gradlew lintDebug run: ./gradlew lintFullDebug lintOfflineDebug
# :dav is a plain JVM module, so it has no testDebugUnitTest — naming only # :dav is a plain JVM module, so it has no testDebugUnitTest — naming only
# that task would compile the vendored suite and run none of it, which is # that task would compile the vendored suite and run none of it, which is
# the whole safety argument in dav/PROVENANCE.md. # the whole safety argument in dav/PROVENANCE.md.
- name: Unit tests - name: Unit tests
if: steps.scope.outputs.code == 'true' if: steps.scope.outputs.code == 'true'
run: ./gradlew testDebugUnitTest :dav:test :caldav:test run: ./gradlew testFullDebugUnitTest testOfflineDebugUnitTest :dav:test :caldav:test
- name: Assemble debug APK - name: Assemble debug APKs
if: steps.scope.outputs.code == 'true' if: steps.scope.outputs.code == 'true'
run: ./gradlew assembleDebug run: ./gradlew assembleDebug
+1 -1
View File
@@ -2,7 +2,7 @@ name: Translations
# Fast, SDK-free parity check for translation resources, so Weblate PRs (which # Fast, SDK-free parity check for translation resources, so Weblate PRs (which
# only touch values-*/strings.xml) get quick feedback without the full Android # only touch values-*/strings.xml) get quick feedback without the full Android
# build. The deeper checks still run in CI via lintDebug (ExtraTranslation). # build. The deeper checks still run in CI via lintFullDebug (ExtraTranslation).
# #
# Runs on every PR (no path filter) so the required "Translations / check" # Runs on every PR (no path filter) so the required "Translations / check"
# status is always reported — like the `ci` job. A path-filtered workflow is # status is always reported — like the `ci` job. A path-filtered workflow is
+11 -6
View File
@@ -70,10 +70,12 @@ jobs:
submodules: recursive submodules: recursive
- name: Setup Java - name: Setup Java
# JetBrains 21 is what gradle-daemon-jvm.properties asks for; installing it
# here keeps Gradle from downloading it via foojay.
uses: actions/setup-java@v4 uses: actions/setup-java@v4
with: with:
distribution: 'zulu' distribution: 'jetbrains'
java-version: '17' java-version: '21'
- name: Setup Android SDK - name: Setup Android SDK
uses: android-actions/setup-android@v3 uses: android-actions/setup-android@v3
@@ -126,7 +128,7 @@ jobs:
grep -E 'versionName|versionCode' app/build.gradle.kts grep -E 'versionName|versionCode' app/build.gradle.kts
- name: Unit tests - name: Unit tests
run: ./gradlew testDebugUnitTest run: ./gradlew testFullDebugUnitTest
# The real app key, same as a stable release: a beta has to update in # 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. # place to the next beta and to the stable version.
@@ -145,7 +147,8 @@ jobs:
storeFile=upload-keystore.jks storeFile=upload-keystore.jks
EOF EOF
- name: Build release APK # Both flavors; the offline one goes to Codeberg only (issue #39).
- name: Build release APKs
run: ./gradlew assembleRelease run: ./gradlew assembleRelease
# Notes = a `## [X.Y.Z-beta.N]` section if there is one, else # Notes = a `## [X.Y.Z-beta.N]` section if there is one, else
@@ -160,7 +163,8 @@ jobs:
bash scripts/release_notes.sh "$VERSION" > release-notes.md bash scripts/release_notes.sh "$VERSION" > release-notes.md
cat release-notes.md cat release-notes.md
TAG="v$VERSION" PRERELEASE=true NOTES_FILE=release-notes.md \ TAG="v$VERSION" PRERELEASE=true NOTES_FILE=release-notes.md \
MAPPING=app/build/outputs/mapping/release/mapping.txt \ MAPPING=app/build/outputs/mapping/fullRelease/mapping.txt \
MAPPING_OFFLINE=app/build/outputs/mapping/offlineRelease/mapping.txt \
bash scripts/publish_gitea_release.sh bash scripts/publish_gitea_release.sh
# The point of the whole workflow, so NOT continue-on-error. # The point of the whole workflow, so NOT continue-on-error.
@@ -172,5 +176,6 @@ jobs:
run: | run: |
set -e set -e
TAG="v$VERSION" PRERELEASE=true NOTES_FILE=release-notes.md \ TAG="v$VERSION" PRERELEASE=true NOTES_FILE=release-notes.md \
APK=app/build/outputs/apk/release/app-release.apk \ 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 bash scripts/publish_codeberg_release.sh
+16 -10
View File
@@ -120,10 +120,12 @@ jobs:
submodules: recursive submodules: recursive
- name: Setup Java - name: Setup Java
# JetBrains 21 is what gradle-daemon-jvm.properties asks for; installing it
# here keeps Gradle from downloading it via foojay.
uses: actions/setup-java@v4 uses: actions/setup-java@v4
with: with:
distribution: 'zulu' distribution: 'jetbrains'
java-version: '17' java-version: '21'
- name: Setup Android SDK - name: Setup Android SDK
uses: android-actions/setup-android@v3 uses: android-actions/setup-android@v3
@@ -182,7 +184,7 @@ jobs:
# Test the exact commit being shipped (only on a real release). # Test the exact commit being shipped (only on a real release).
- name: Unit tests - name: Unit tests
if: env.IS_RELEASE == 'true' if: env.IS_RELEASE == 'true'
run: ./gradlew testDebugUnitTest run: ./gradlew testFullDebugUnitTest
- name: Setup Android Keystore - name: Setup Android Keystore
if: env.IS_RELEASE == 'true' if: env.IS_RELEASE == 'true'
@@ -200,7 +202,9 @@ jobs:
storeFile=upload-keystore.jks storeFile=upload-keystore.jks
EOF EOF
- name: Build release APK # Both flavors. Only full goes to F-Droid and Play; the offline one
# (issue #39) is a Codeberg release asset and nothing else.
- name: Build release APKs
if: env.IS_RELEASE == 'true' if: env.IS_RELEASE == 'true'
run: ./gradlew assembleRelease run: ./gradlew assembleRelease
@@ -256,7 +260,7 @@ jobs:
run: | run: |
set -e set -e
mkdir -p fdroid/repo mkdir -p fdroid/repo
cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/agendula_v${VERSION}.apk" cp app/build/outputs/apk/full/release/app-full-release.apk "fdroid/repo/agendula_v${VERSION}.apk"
# Per-version "What's New": ensure this version's changelog exists in the # Per-version "What's New": ensure this version's changelog exists in the
# fastlane tree (committed at release-cut time for the official repo; this # fastlane tree (committed at release-cut time for the official repo; this
@@ -318,7 +322,8 @@ jobs:
set -e set -e
bash scripts/release_notes.sh "$VERSION" > release-notes.md bash scripts/release_notes.sh "$VERSION" > release-notes.md
TAG="v$VERSION" NOTES_FILE=release-notes.md \ TAG="v$VERSION" NOTES_FILE=release-notes.md \
MAPPING=app/build/outputs/mapping/release/mapping.txt \ MAPPING=app/build/outputs/mapping/fullRelease/mapping.txt \
MAPPING_OFFLINE=app/build/outputs/mapping/offlineRelease/mapping.txt \
bash scripts/publish_gitea_release.sh bash scripts/publish_gitea_release.sh
# Publish the release on Codeberg, which is canonical for tags and # Publish the release on Codeberg, which is canonical for tags and
@@ -341,7 +346,8 @@ jobs:
set -e set -e
[ -s release-notes.md ] || bash scripts/release_notes.sh "$VERSION" > release-notes.md [ -s release-notes.md ] || bash scripts/release_notes.sh "$VERSION" > release-notes.md
TAG="v$VERSION" NOTES_FILE=release-notes.md \ TAG="v$VERSION" NOTES_FILE=release-notes.md \
APK=app/build/outputs/apk/release/app-release.apk \ 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 bash scripts/publish_codeberg_release.sh
# Play takes an App Bundle, not the APK: a second artifact from the same # Play takes an App Bundle, not the APK: a second artifact from the same
@@ -356,7 +362,7 @@ jobs:
- name: Build release AAB - name: Build release AAB
if: env.IS_RELEASE == 'true' if: env.IS_RELEASE == 'true'
continue-on-error: true continue-on-error: true
run: ./gradlew bundleRelease run: ./gradlew bundleFullRelease
# NOT actions/upload-artifact@v4: its client refuses any non-github.com # NOT actions/upload-artifact@v4: its client refuses any non-github.com
# server as unsupported GHES (go-gitea/gitea#36024). This fork drops that # server as unsupported GHES (go-gitea/gitea#36024). This fork drops that
@@ -368,7 +374,7 @@ jobs:
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 # v4 uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 # v4
with: with:
name: release-aab-${{ needs.detect.outputs.version }} name: release-aab-${{ needs.detect.outputs.version }}
path: app/build/outputs/bundle/release/app-release.aab path: app/build/outputs/bundle/fullRelease/app-full-release.aab
if-no-files-found: error if-no-files-found: error
retention-days: 14 retention-days: 14
@@ -462,7 +468,7 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
# Absolute: a lane body runs from fastlane/, not the workspace root. # Absolute: a lane body runs from fastlane/, not the workspace root.
AAB="$GITHUB_WORKSPACE/dist/app-release.aab" AAB="$GITHUB_WORKSPACE/dist/app-full-release.aab"
test -f "$AAB" || { echo "No AAB at $AAB — the artifact handoff failed." >&2; ls -la dist || true; exit 1; } test -f "$AAB" || { echo "No AAB at $AAB — the artifact handoff failed." >&2; ls -la dist || true; exit 1; }
bundle exec fastlane deploy \ bundle exec fastlane deploy \
aab:"$AAB" \ aab:"$AAB" \
+29
View File
@@ -7,6 +7,35 @@ All notable changes to this project are documented here. The format follows
## [Unreleased] ## [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).
### 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.
## [1.0.0] - 2026-09-21 ## [1.0.0] - 2026-09-21
### Added ### Added
+3 -3
View File
@@ -28,9 +28,9 @@ layer.
## Build, test, lint ## Build, test, lint
```sh ```sh
./gradlew :app:assembleDebug # build the debug APK ./gradlew :app:assembleDebug # build the debug APKs (full + offline)
./gradlew :app:testDebugUnitTest # JVM unit tests (JUnit5 + Truth + Turbine) ./gradlew :app:testFullDebugUnitTest # JVM unit tests (JUnit5 + Truth + Turbine)
./gradlew lintDebug # Android lint (CI runs this on every PR) ./gradlew lintFullDebug lintOfflineDebug # Android lint (CI runs this on every PR)
``` ```
CI (`.forgejo/workflows/ci.yaml`, on Codeberg) runs a reproducible-release invariant check, CI (`.forgejo/workflows/ci.yaml`, on Codeberg) runs a reproducible-release invariant check,
+12
View File
@@ -119,9 +119,21 @@ APK and a `.sha256` checksum attached — the same APK the F-Droid repository
serves. For automatic updates from there, use serves. For automatic updates from there, use
**[Obtainium](https://github.com/ImranR98/Obtainium)** and **[Obtainium](https://github.com/ImranR98/Obtainium)** and
**[add Agendula in one tap](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/agendula)**. **[add Agendula in one tap](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/agendula)**.
Each release carries two APKs (see [Offline version](#offline-version)), so set
*Filter APKs by regular expression* to `^agendula_v` to always get the regular
app.
Betas of upcoming versions are published there too, as pre-releases; to test Betas of upcoming versions are published there too, as pre-releases; to test
them, switch on *Include prereleases* for Agendula in Obtainium. 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 ### Build from source
```sh ```sh
+54 -7
View File
@@ -1,3 +1,4 @@
import com.android.build.api.artifact.SingleArtifact
import java.util.Properties import java.util.Properties
import java.io.FileInputStream import java.io.FileInputStream
@@ -32,8 +33,8 @@ android {
// (1.0.x: 1.0.0 -> 10000; from 1.1.0: 1.1.0-beta.1 -> 1010001, // (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. // 1.1.0 -> 1010099), and CI fails if the committed one doesn't match.
// See docs/RELEASING.md. // See docs/RELEASING.md.
versionCode = 10000 versionCode = 1010003
versionName = "1.0.0" versionName = "1.1.0-beta.3"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -46,6 +47,20 @@ android {
resValue("string", "sync_authority", "de.jeanlucmakiola.agendula.sync") resValue("string", "sync_authority", "de.jeanlucmakiola.agendula.sync")
} }
// `offline` (#39): no sync code, network libraries or network permission.
flavorDimensions += "network"
productFlavors {
create("full") {
dimension = "network"
buildConfigField("boolean", "SYNC_ENABLED", "true")
}
create("offline") {
dimension = "network"
applicationIdSuffix = ".offline"
buildConfigField("boolean", "SYNC_ENABLED", "false")
}
}
signingConfigs { signingConfigs {
if (keystorePropertiesFile.exists()) { if (keystorePropertiesFile.exists()) {
create("release") { create("release") {
@@ -208,26 +223,26 @@ dependencies {
// @HiltWorker plumbing. // @HiltWorker plumbing.
implementation(libs.androidx.work.runtime.ktx) implementation(libs.androidx.work.runtime.ktx)
// Custom Tabs: the Nextcloud login flow hands the browser an approval page. // Custom Tabs: the Nextcloud login flow hands the browser an approval page.
implementation(libs.androidx.browser) "fullImplementation"(libs.androidx.browser)
implementation(libs.androidx.hilt.work) implementation(libs.androidx.hilt.work)
ksp(libs.androidx.hilt.compiler) ksp(libs.androidx.hilt.compiler)
// Push sync: a UnifiedPush distributor delivers the server's WebDAV-Push messages. // Push sync: a UnifiedPush distributor delivers the server's WebDAV-Push messages.
implementation(libs.unifiedpush.connector) "fullImplementation"(libs.unifiedpush.connector)
// RFC 5545 recurrence expansion, in-process; see the catalog for the pin. // RFC 5545 recurrence expansion, in-process; see the catalog for the pin.
implementation(libs.dmfs.lib.recur) implementation(libs.dmfs.lib.recur)
// Vendored dav4jvm — the CalDAV protocol layer. See dav/PROVENANCE.md. // Vendored dav4jvm — the CalDAV protocol layer. See dav/PROVENANCE.md.
implementation(project(":dav")) "fullImplementation"(project(":dav"))
// Discovery, auth and Nextcloud Login Flow v2. // Discovery, auth and Nextcloud Login Flow v2.
implementation(project(":caldav")) "fullImplementation"(project(":caldav"))
// :dav gets org.xmlpull.v1 from the Android framework at runtime and declares // :dav gets org.xmlpull.v1 from the Android framework at runtime and declares
// xpp3 compileOnly, which is not transitive. Unit tests run on a plain JVM // xpp3 compileOnly, which is not transitive. Unit tests run on a plain JVM
// with no framework, and android.jar's stub factory returns null under // with no framework, and android.jar's stub factory returns null under
// isReturnDefaultValues — so anything touching XmlUtils would NPE without a // isReturnDefaultValues — so anything touching XmlUtils would NPE without a
// real implementation here. // real implementation here.
testImplementation(libs.xpp3) "testFullImplementation"(libs.xpp3)
implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx) implementation(libs.androidx.room.ktx)
@@ -246,6 +261,7 @@ dependencies {
implementation(libs.floret.core.crash) implementation(libs.floret.core.crash)
implementation(libs.floret.identity) implementation(libs.floret.identity)
implementation(libs.floret.components) implementation(libs.floret.components)
implementation(libs.floret.glance)
debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest) debugImplementation(libs.androidx.ui.test.manifest)
@@ -265,3 +281,34 @@ dependencies {
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4) androidTestImplementation(libs.androidx.ui.test.junit4)
} }
/** Fails the build if the offline flavor's merged manifest asks for the network. */
abstract class VerifyNoNetworkPermissions : DefaultTask() {
@get:InputFile
@get:PathSensitive(PathSensitivity.NONE)
abstract val manifest: RegularFileProperty
@TaskAction
fun verify() {
val text = manifest.get().asFile.readText()
val found = listOf("INTERNET", "ACCESS_NETWORK_STATE", "ACCESS_WIFI_STATE", "CHANGE_NETWORK_STATE")
.filter { "\"android.permission.$it\"" in text }
if (found.isNotEmpty()) {
throw GradleException(
"The offline flavor's merged manifest declares ${found.joinToString()}. " +
"Remove it in app/src/offline/AndroidManifest.xml with tools:node=\"remove\".",
)
}
}
}
androidComponents {
onVariants(selector().withFlavor("network" to "offline")) { variant ->
val name = variant.name.replaceFirstChar { it.uppercase() }
val verify = tasks.register<VerifyNoNetworkPermissions>("verify${name}Manifest") {
manifest.set(variant.artifacts.get(SingleArtifact.MERGED_MANIFEST))
}
tasks.matching { it.name in setOf("package$name", "package${name}Bundle", "check") }
.configureEach { dependsOn(verify) }
}
}
@@ -0,0 +1,529 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "14b159c51416ba27653f6320ac7f6f03",
"entities": [
{
"tableName": "accounts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `display_name` TEXT NOT NULL, `principal_url` TEXT, `home_set_url` TEXT, `username` TEXT, `last_sync_at` INTEGER, `last_sync_error` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "display_name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "principalUrl",
"columnName": "principal_url",
"affinity": "TEXT"
},
{
"fieldPath": "homeSetUrl",
"columnName": "home_set_url",
"affinity": "TEXT"
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT"
},
{
"fieldPath": "lastSyncAt",
"columnName": "last_sync_at",
"affinity": "INTEGER"
},
{
"fieldPath": "lastSyncError",
"columnName": "last_sync_error",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "task_lists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `account_id` INTEGER, `is_visible` INTEGER NOT NULL DEFAULT 1, `is_synced` INTEGER NOT NULL DEFAULT 1, `owner` TEXT, `is_read_only` INTEGER NOT NULL DEFAULT 0, `sort_order` INTEGER NOT NULL DEFAULT 0, `href` TEXT, `ctag` TEXT, `sync_token` TEXT, `is_dirty` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`account_id`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "account_id",
"affinity": "INTEGER"
},
{
"fieldPath": "isVisible",
"columnName": "is_visible",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "1"
},
{
"fieldPath": "isSynced",
"columnName": "is_synced",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "1"
},
{
"fieldPath": "owner",
"columnName": "owner",
"affinity": "TEXT"
},
{
"fieldPath": "isReadOnly",
"columnName": "is_read_only",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "sortOrder",
"columnName": "sort_order",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "href",
"columnName": "href",
"affinity": "TEXT"
},
{
"fieldPath": "ctag",
"columnName": "ctag",
"affinity": "TEXT"
},
{
"fieldPath": "syncToken",
"columnName": "sync_token",
"affinity": "TEXT"
},
{
"fieldPath": "isDirty",
"columnName": "is_dirty",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_task_lists_account_id",
"unique": false,
"columnNames": [
"account_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_task_lists_account_id` ON `${TABLE_NAME}` (`account_id`)"
}
],
"foreignKeys": [
{
"table": "accounts",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"account_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "tasks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `list_id` INTEGER NOT NULL, `uid` TEXT NOT NULL, `href` TEXT, `etag` TEXT, `title` TEXT, `description` TEXT, `location` TEXT, `url` TEXT, `color` INTEGER, `categories` TEXT NOT NULL DEFAULT '', `status` INTEGER NOT NULL DEFAULT 0, `percent_complete` INTEGER, `completed_at` INTEGER, `priority` INTEGER NOT NULL DEFAULT 0, `classification` INTEGER, `dtstart` INTEGER, `due` INTEGER, `duration` TEXT, `is_all_day` INTEGER NOT NULL DEFAULT 0, `timezone` TEXT, `rrule` TEXT, `rdate` TEXT, `exdate` TEXT, `recurrence_id` INTEGER, `master_id` INTEGER, `parent_id` INTEGER, `sort_order` INTEGER NOT NULL DEFAULT 0, `created_at` INTEGER, `last_modified` INTEGER, `sequence` INTEGER NOT NULL DEFAULT 0, `is_dirty` INTEGER NOT NULL DEFAULT 0, `is_deleted` INTEGER NOT NULL DEFAULT 0, `unknown_properties` TEXT, FOREIGN KEY(`list_id`) REFERENCES `task_lists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`master_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parent_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "listId",
"columnName": "list_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "href",
"columnName": "href",
"affinity": "TEXT"
},
{
"fieldPath": "etag",
"columnName": "etag",
"affinity": "TEXT"
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT"
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT"
},
{
"fieldPath": "location",
"columnName": "location",
"affinity": "TEXT"
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT"
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "INTEGER"
},
{
"fieldPath": "categories",
"columnName": "categories",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "percentComplete",
"columnName": "percent_complete",
"affinity": "INTEGER"
},
{
"fieldPath": "completedAt",
"columnName": "completed_at",
"affinity": "INTEGER"
},
{
"fieldPath": "priority",
"columnName": "priority",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "classification",
"columnName": "classification",
"affinity": "INTEGER"
},
{
"fieldPath": "dtstart",
"columnName": "dtstart",
"affinity": "INTEGER"
},
{
"fieldPath": "due",
"columnName": "due",
"affinity": "INTEGER"
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "TEXT"
},
{
"fieldPath": "isAllDay",
"columnName": "is_all_day",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "timezone",
"columnName": "timezone",
"affinity": "TEXT"
},
{
"fieldPath": "rrule",
"columnName": "rrule",
"affinity": "TEXT"
},
{
"fieldPath": "rdate",
"columnName": "rdate",
"affinity": "TEXT"
},
{
"fieldPath": "exdate",
"columnName": "exdate",
"affinity": "TEXT"
},
{
"fieldPath": "recurrenceId",
"columnName": "recurrence_id",
"affinity": "INTEGER"
},
{
"fieldPath": "masterId",
"columnName": "master_id",
"affinity": "INTEGER"
},
{
"fieldPath": "parentId",
"columnName": "parent_id",
"affinity": "INTEGER"
},
{
"fieldPath": "sortOrder",
"columnName": "sort_order",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER"
},
{
"fieldPath": "lastModified",
"columnName": "last_modified",
"affinity": "INTEGER"
},
{
"fieldPath": "sequence",
"columnName": "sequence",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isDirty",
"columnName": "is_dirty",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isDeleted",
"columnName": "is_deleted",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "unknownProperties",
"columnName": "unknown_properties",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_tasks_list_id_is_deleted",
"unique": false,
"columnNames": [
"list_id",
"is_deleted"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_list_id_is_deleted` ON `${TABLE_NAME}` (`list_id`, `is_deleted`)"
},
{
"name": "index_tasks_parent_id",
"unique": false,
"columnNames": [
"parent_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_parent_id` ON `${TABLE_NAME}` (`parent_id`)"
},
{
"name": "index_tasks_master_id_recurrence_id",
"unique": false,
"columnNames": [
"master_id",
"recurrence_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_master_id_recurrence_id` ON `${TABLE_NAME}` (`master_id`, `recurrence_id`)"
},
{
"name": "index_tasks_is_dirty",
"unique": false,
"columnNames": [
"is_dirty"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_is_dirty` ON `${TABLE_NAME}` (`is_dirty`)"
},
{
"name": "index_tasks_list_id_uid_recurrence_id",
"unique": true,
"columnNames": [
"list_id",
"uid",
"recurrence_id"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tasks_list_id_uid_recurrence_id` ON `${TABLE_NAME}` (`list_id`, `uid`, `recurrence_id`)"
}
],
"foreignKeys": [
{
"table": "task_lists",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"list_id"
],
"referencedColumns": [
"id"
]
},
{
"table": "tasks",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"master_id"
],
"referencedColumns": [
"id"
]
},
{
"table": "tasks",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"parent_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "task_alarms",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `task_id` INTEGER NOT NULL, `minutes_before` INTEGER NOT NULL, `reference` TEXT NOT NULL DEFAULT 'DUE', `message` TEXT, FOREIGN KEY(`task_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "taskId",
"columnName": "task_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "minutesBefore",
"columnName": "minutes_before",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "reference",
"columnName": "reference",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'DUE'"
},
{
"fieldPath": "message",
"columnName": "message",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_task_alarms_task_id",
"unique": false,
"columnNames": [
"task_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_task_alarms_task_id` ON `${TABLE_NAME}` (`task_id`)"
}
],
"foreignKeys": [
{
"table": "tasks",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"task_id"
],
"referencedColumns": [
"id"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '14b159c51416ba27653f6320ac7f6f03')"
]
}
}
Binary file not shown.
@@ -212,6 +212,15 @@ class OneShotImportTest {
assertThat(db.alarms().all().map { it.message }).doesNotContain("Errands") assertThat(db.alarms().all().map { it.message }).doesNotContain("Errands")
} }
@Test
fun importsCategoryPropertiesAsTags() {
importer.importFrom(fixture())
val tasks = taskRows()
assertThat(tasks.getValue("Buy milk").categories).containsExactly("Errands")
assertThat(tasks.getValue("Ship the release").categories).isEmpty()
}
// --- running it ----------------------------------------------------------- // --- running it -----------------------------------------------------------
@Test @Test
@@ -8,27 +8,7 @@ import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
/** /** Migrations, proven against the committed schemas in `app/schemas/`. */
* The migration harness, proven against the committed schema in `app/schemas/`.
*
* There is one schema version today, so all there is to assert is that the helper
* can build v1 from the exported JSON, seed it, and validate it back — i.e. the
* export, the assets wiring and the identity hash all line up. That is the point:
* the first real migration only has to add its own case.
*
* **Adding a v1 → v2 case.** When sync adds columns, bump [TasksDatabase]'s
* `version`, let KSP export `2.json`, declare the `Migration(1, 2)` next to the
* database, and add a test here shaped like this:
*
* ```
* helper.createDatabase(TEST_DB, 1).use { db ->
* db.execSQL("INSERT INTO task_lists (name, color) VALUES ('Groceries', 0)")
* }
* helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2).use { db ->
* // read the seeded rows back — validation proves the shape, not the data
* }
* ```
*/
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class TasksDatabaseMigrationTest { class TasksDatabaseMigrationTest {
@@ -62,6 +42,39 @@ 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 { private companion object {
const val TEST_DB = "migration-test.db" const val TEST_DB = "migration-test.db"
} }
@@ -107,6 +107,17 @@ class ExternalImportTest {
assertThat(importer.hasRun.first()).isTrue() assertThat(importer.hasRun.first()).isTrue()
} }
@Test
fun copiesTagsCleanedUp() = runBlocking {
source.lists = listOf(list(7, "Errands"))
source.tasks = mapOf(7L to listOf(task(100, "Milk", tags = listOf("Shop", " Shop ", "", "Home"))))
importer.run()
assertThat(db.tasks().tasks(listId = null, includeCompleted = true).single().task.categories)
.containsExactly("Shop", "Home").inOrder()
}
/** Every list arrives device-only: the account belongs to the sync app. */ /** Every list arrives device-only: the account belongs to the sync app. */
@Test @Test
fun importedListsAreDeviceOnly() = runBlocking { fun importedListsAreDeviceOnly() = runBlocking {
@@ -241,6 +252,7 @@ class ExternalImportTest {
title: String, title: String,
uid: String? = "uid-$id", uid: String? = "uid-$id",
parentId: Long? = null, parentId: Long? = null,
tags: List<String> = emptyList(),
) = ExportTask( ) = ExportTask(
taskId = id, taskId = id,
uid = uid, uid = uid,
@@ -260,6 +272,7 @@ class ExternalImportTest {
rrule = null, rrule = null,
rdate = null, rdate = null,
parentId = parentId, parentId = parentId,
tags = tags,
) )
private companion object { private companion object {
+88
View File
@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- CalDAV sync and UnifiedPush; everything here needs the network. -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- CalDAV sync. ACCESS_NETWORK_STATE is merged in by work-runtime anyway,
but it shows in F-Droid's permission diff, so declare it deliberately
rather than letting it appear from nowhere.
READ_SYNC_SETTINGS / WRITE_SYNC_SETTINGS are what the ContentResolver
sync APIs need. No FOREGROUND_SERVICE: sync is a plain worker, and the
dataSync FGS type would bring the Android 15 six-hours-per-24 budget
(whose failure mode is a fatal RemoteServiceException) and a Play
requirement for a video demo.
Two more permissions appear in the merged manifest without being
declared here, and both come from work-runtime: WAKE_LOCK, and
FOREGROUND_SERVICE. The latter is not us taking the FGS route — below
API 31 WorkManager implements expedited work with a foreground service,
and minSdk is 29, so it is load-bearing for the "Sync now" button.
Removing it with tools:node="remove" would break expedited work on
exactly the older devices that need it most. Noted because it shows in
F-Droid's permission diff and would otherwise look unexplained. -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
<queries>
<!-- Custom Tabs provider detection. Without this entry it silently finds
nothing on API 30+, and the Nextcloud login flow falls back to an
external browser for no visible reason. -->
<intent>
<action android:name="android.support.customtabs.action.CustomTabsService" />
</intent>
</queries>
<application android:networkSecurityConfig="@xml/network_security_config">
<!-- Sync plumbing. The stub provider exists only to give the sync
adapter an authority to register against: Agendula publishes no real
ContentProvider since :provider was deleted, and without an authority
ContentService.hasAuthorityAccess() makes every ContentResolver sync
call a silent no-op at targetSdk >= 34. -->
<provider
android:name=".data.sync.SyncStubProvider"
android:authorities="${applicationId}.sync"
android:exported="false"
android:syncable="true" />
<!-- Exported and guarded by ACCOUNT_MANAGER. Note that
android.permission.ACCOUNT_AUTHENTICATOR does not exist. -->
<service
android:name=".data.sync.AuthenticatorService"
android:exported="true"
android:permission="android.permission.ACCOUNT_MANAGER">
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator" />
</intent-filter>
<meta-data
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/authenticator" />
</service>
<service
android:name=".data.sync.SyncAdapterService"
android:exported="true"
android:permission="android.permission.BIND_SYNC_ADAPTER">
<intent-filter>
<action android:name="android.content.SyncAdapter" />
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_adapter" />
</service>
<!-- UnifiedPush: the connector binds this to deliver endpoints and
WebDAV-Push messages. Not exported; the connector's own receiver is
what distributors talk to. -->
<service
android:name=".data.sync.push.AgendulaPushService"
android:exported="false">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT" />
</intent-filter>
</service>
</application>
</manifest>
@@ -0,0 +1,131 @@
package de.jeanlucmakiola.agendula.data.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import de.jeanlucmakiola.agendula.data.sync.AccountCreator
import de.jeanlucmakiola.agendula.data.sync.AccountRepository
import de.jeanlucmakiola.agendula.data.sync.CalDavGateway
import de.jeanlucmakiola.agendula.data.sync.LoginFlowRecord
import de.jeanlucmakiola.agendula.data.sync.OkHttpCalDavGateway
import de.jeanlucmakiola.agendula.data.sync.PendingLoginFlowStore
import de.jeanlucmakiola.agendula.data.sync.RemoteListRepository
import de.jeanlucmakiola.agendula.data.sync.RemoteLists
import de.jeanlucmakiola.agendula.data.sync.SyncNoticeNotifier
import de.jeanlucmakiola.agendula.data.sync.SyncOnEdit
import de.jeanlucmakiola.agendula.data.sync.SyncRequests
import de.jeanlucmakiola.agendula.data.sync.SyncTrigger
import de.jeanlucmakiola.agendula.data.sync.push.PushRegistrar
import de.jeanlucmakiola.agendula.data.tasks.room.LocalWriteListener
import javax.inject.Provider
import javax.inject.Singleton
/** See [CredentialsDataStore] for why this is a separate file. */
private val Context.credentialsDataStore: DataStore<Preferences> by preferencesDataStore(
name = CREDENTIALS_DATASTORE,
corruptionHandler = replaceCorrupted(),
)
/** See [SyncStateDataStore] for why this is a separate file. */
private val Context.syncStateDataStore: DataStore<Preferences> by preferencesDataStore(
name = SYNC_STATE_DATASTORE,
corruptionHandler = replaceCorrupted(),
)
/**
* Named here and in `backup_rules.xml` / `data_extraction_rules.xml`, which
* exclude `datastore/$CREDENTIALS_DATASTORE.preferences_pb` by this name.
*/
const val CREDENTIALS_DATASTORE = "agendula_credentials"
/**
* Named here and in `backup_rules.xml` / `data_extraction_rules.xml`, which
* exclude `datastore/$SYNC_STATE_DATASTORE.preferences_pb` by this name.
*/
const val SYNC_STATE_DATASTORE = "agendula_sync_state"
/** CalDAV sync: the `full` flavor's side of the seams `main` declares. */
@Module
@InstallIn(SingletonComponent::class)
abstract class SyncBindModule {
@Binds
@Singleton
abstract fun bindCalDavGateway(impl: OkHttpCalDavGateway): CalDavGateway
@Binds
@Singleton
abstract fun bindAccountCreator(impl: AccountRepository): AccountCreator
@Binds
@Singleton
abstract fun bindLoginFlowRecord(impl: PendingLoginFlowStore): LoginFlowRecord
@Binds
@Singleton
abstract fun bindLocalWriteListener(impl: SyncOnEdit): LocalWriteListener
@Binds
@Singleton
abstract fun bindRemoteLists(impl: RemoteListRepository): RemoteLists
@Binds
@Singleton
abstract fun bindSyncRequests(impl: SyncTrigger): SyncRequests
@Binds
@IntoSet
abstract fun bindSyncNoticeChannel(impl: SyncNoticeNotifier): ChannelRefresher
}
@Module
@InstallIn(SingletonComponent::class)
object SyncProvideModule {
@Provides
@Singleton
@CredentialsDataStore
fun provideCredentialsDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
context.credentialsDataStore
@Provides
@Singleton
@SyncStateDataStore
fun provideSyncStateDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
context.syncStateDataStore
/**
* Sync hard on app open: the periodic worker's interval is a floor, and in
* the `rare` and `restricted` App Standby buckets it may not have run at
* all. `KEEP` makes rescheduling idempotent, so this also repairs a schedule
* lost to "clear app data" or to a restore.
*/
@Provides
@IntoSet
fun syncOnOpenHook(
accounts: Provider<AccountRepository>,
syncTrigger: Provider<SyncTrigger>,
pendingLoginFlows: Provider<PendingLoginFlowStore>,
push: Provider<PushRegistrar>,
): LaunchHook = LaunchHook {
runCatching {
accounts.get().rescheduleAll()
accounts.get().syncable().forEach { syncTrigger.get().enqueue(it.displayName) }
// A login flow the previous process died in the middle of.
// Its password, if the user approved, exists nowhere else.
pendingLoginFlows.get().reclaim()
}
// Last and on its own: it waits on the network, and must not hold up
// the reclaim above. Re-registering on open is what the connector
// recommends.
runCatching { push.get().updateAll() }
}
}
@@ -0,0 +1,34 @@
package de.jeanlucmakiola.agendula.data.di
import javax.inject.Qualifier
/**
* Marks the DataStore holding **only** the Keystore-encrypted app passwords.
*
* A separate file from `agendula_prefs` on purpose: Auto Backup includes
* `datastore/`, and a restored ciphertext is permanently undecryptable because
* Keystore keys are non-exportable. Its own file is what lets the backup rules
* exclude the credentials and nothing else — excluding the whole database or
* all of DataStore would trade a latent bug for a live one.
*/
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class CredentialsDataStore
/**
* Marks the DataStore holding per-device **sync bookkeeping** — the quarantine
* counters and the full-reconciliation clock.
*
* Its own file for the same reason the credentials have one: Auto Backup
* includes `datastore/`, and every value in here is a statement about *this*
* device's conversation with a server. Restored onto a new install they are all
* lies, and two of them are dangerous — a restored "reconciled recently" makes
* the engine trust a sync token for another day, which is precisely the silently
* pruned change log the full path exists to catch, and a restored quarantine
* count silently skips resources that were never tried here.
*
* Not user data, so nothing is lost by excluding it.
*/
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class SyncStateDataStore
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.agendula.data.sync package de.jeanlucmakiola.agendula.data.sync
import de.jeanlucmakiola.agendula.data.di.IoDispatcher import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.sync.RemoteLists.Outcome
import de.jeanlucmakiola.agendula.data.sync.push.PushStore import de.jeanlucmakiola.agendula.data.sync.push.PushStore
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StorageMode import de.jeanlucmakiola.agendula.data.tasks.StorageMode
@@ -53,38 +54,7 @@ class RemoteListRepository @Inject constructor(
private val resolver: ProviderResolver, private val resolver: ProviderResolver,
private val push: PushStore, private val push: PushStore,
@IoDispatcher private val io: CoroutineDispatcher, @IoDispatcher private val io: CoroutineDispatcher,
) { ) : RemoteLists {
/** Why a collection write did not happen, in a form the UI can translate. */
sealed interface Outcome {
data object Done : Outcome
/** The server said no, and will say no again. */
data class Refused(val code: Int) : Outcome
/** The server could not be reached. Worth another try. */
data object Unreachable : Outcome
/** This account cannot make collections at all — iCloud, Posteo, Google. */
data object Unsupported : Outcome
/** Ours is a read-only share; the write belongs to whoever owns it. */
data object ReadOnly : Outcome
/** The account is gone, stopped, or has no credential we can decrypt. */
data object NoAccount : Outcome
/**
* The server answered something this call cannot make sense of.
*
* ⚠️ Not [Unreachable]. `CollectionOutcome` is one type across create,
* update and delete, so each of them has branches the other's method
* can return and its own cannot — and mapping those to [Unreachable]
* told someone sitting on wifi that they were offline. Unreachable is a
* claim about the network, and this is not one.
*/
data object Unexpected : Outcome
}
/** /**
* The accounts a new list may be created on, freshest answer first. * The accounts a new list may be created on, freshest answer first.
@@ -95,7 +65,7 @@ class RemoteListRepository @Inject constructor(
* able to say so, and the only moment that costs nothing is while the user * able to say so, and the only moment that costs nothing is while the user
* is looking at the picker. * is looking at the picker.
*/ */
suspend fun creatableAccounts(): List<AccountEntity> = withContext(io) { override suspend fun creatableAccounts(): List<AccountEntity> = withContext(io) {
// ⚠️ Empty in External mode, whatever the accounts table holds. The // ⚠️ Empty in External mode, whatever the accounts table holds. The
// lists on screen then come from a third-party provider, so a row // lists on screen then come from a third-party provider, so a row
// inserted into ours would exist, sync, and be visible to nobody. // inserted into ours would exist, sync, and be visible to nobody.
@@ -120,7 +90,7 @@ class RemoteListRepository @Inject constructor(
* *
* @return the new list's local id, or why there is none. * @return the new list's local id, or why there is none.
*/ */
suspend fun create( override suspend fun create(
accountId: Long, accountId: Long,
name: String, name: String,
color: Int, color: Int,
@@ -189,7 +159,7 @@ class RemoteListRepository @Inject constructor(
* a row that has already been renamed locally by then reads as a rename that * a row that has already been renamed locally by then reads as a rename that
* worked and then quietly reverted on the next sync. * worked and then quietly reverted on the next sync.
*/ */
suspend fun rename(listId: Long, name: String, color: Int): Outcome = withContext(io) { override suspend fun rename(listId: Long, name: String, color: Int): Outcome = withContext(io) {
val list = database.taskLists().entity(listId) ?: return@withContext Outcome.NoAccount val list = database.taskLists().entity(listId) ?: return@withContext Outcome.NoAccount
if (list.isReadOnly) return@withContext Outcome.ReadOnly if (list.isReadOnly) return@withContext Outcome.ReadOnly
val url = list.href?.toHttpUrlOrNull() ?: return@withContext Outcome.NoAccount val url = list.href?.toHttpUrlOrNull() ?: return@withContext Outcome.NoAccount
@@ -227,7 +197,7 @@ class RemoteListRepository @Inject constructor(
* grades 404 and 410 that way — because otherwise a collection someone * grades 404 and 410 that way — because otherwise a collection someone
* removed from another client leaves a row here that nothing can get rid of. * removed from another client leaves a row here that nothing can get rid of.
*/ */
suspend fun delete(listId: Long): Outcome = withContext(io) { override suspend fun delete(listId: Long): Outcome = withContext(io) {
val list = database.taskLists().entity(listId) ?: return@withContext Outcome.Done val list = database.taskLists().entity(listId) ?: return@withContext Outcome.Done
if (list.isReadOnly) return@withContext Outcome.ReadOnly if (list.isReadOnly) return@withContext Outcome.ReadOnly
val url = list.href?.toHttpUrlOrNull() ?: return@withContext Outcome.NoAccount val url = list.href?.toHttpUrlOrNull() ?: return@withContext Outcome.NoAccount
@@ -14,6 +14,7 @@ import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.MainActivity import de.jeanlucmakiola.agendula.MainActivity
import de.jeanlucmakiola.agendula.data.di.ChannelRefresher
import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.agendula.R
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -33,7 +34,7 @@ import javax.inject.Singleton
@Singleton @Singleton
class SyncNoticeNotifier @Inject constructor( class SyncNoticeNotifier @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
) { ) : ChannelRefresher {
fun canPost(): Boolean { fun canPost(): Boolean {
val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
@@ -123,7 +124,7 @@ class SyncNoticeNotifier @Inject constructor(
* extra names the account for routing to Settings → Accounts → it. * extra names the account for routing to Settings → Accounts → it.
*/ */
private fun signInIntent(accountId: Long): Intent = private fun signInIntent(accountId: Long): Intent =
MainActivity.openIntent(context).putExtra(EXTRA_SIGN_IN_ACCOUNT_ID, accountId) MainActivity.openIntent(context).putExtra(MainActivity.EXTRA_SIGN_IN_ACCOUNT_ID, accountId)
private fun ensureSignInChannel() { private fun ensureSignInChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
@@ -170,16 +171,13 @@ class SyncNoticeNotifier @Inject constructor(
} }
/** Re-create the channel, if it exists, in the current language. */ /** Re-create the channel, if it exists, in the current language. */
fun refreshChannel() { override fun refreshChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(NotificationManager::class.java) val manager = context.getSystemService(NotificationManager::class.java)
if (manager.getNotificationChannel(CHANNEL_ID) != null) ensureChannel() if (manager.getNotificationChannel(CHANNEL_ID) != null) ensureChannel()
} }
companion object { companion object {
/** The account a sign-in notification is about, on the intent it opens. */
const val EXTRA_SIGN_IN_ACCOUNT_ID = "de.jeanlucmakiola.agendula.extra.SIGN_IN_ACCOUNT_ID"
private const val CHANNEL_ID = "sync_notices" private const val CHANNEL_ID = "sync_notices"
private const val NOTIFICATION_ID = 2 private const val NOTIFICATION_ID = 2
private const val SIGN_IN_CHANNEL_ID = "account_sign_in" private const val SIGN_IN_CHANNEL_ID = "account_sign_in"
@@ -35,7 +35,11 @@ import kotlin.time.Duration.Companion.seconds
@Singleton @Singleton
class SyncTrigger @Inject constructor( class SyncTrigger @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
) { ) : SyncRequests {
override fun syncNow(accountName: String) {
enqueue(accountName)
}
/** /**
* Starts a sync now. * Starts a sync now.
@@ -0,0 +1,35 @@
package de.jeanlucmakiola.agendula.ui.onboarding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.ui.accounts.add.ADD_ACCOUNT_STEPS
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountScreen
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountViewModel
/** The inline wizard's shortest length, before a provider is picked. */
internal const val ACCOUNT_STEP_SLOTS = ADD_ACCOUNT_STEPS
/** [OnboardingStep.Account]: the add-account wizard, given the whole screen. */
@Composable
internal fun AccountStep(state: OnboardingUiState, viewModel: OnboardingViewModel) {
// The same instance the wizard resolves for itself, so its length can be
// read here without keeping a second copy of its state.
val account: AddAccountViewModel = hiltViewModel()
val accountState by account.state.collectAsStateWithLifecycle()
// ⚠️ The wizard grows a step for a provider that needs an app password
// minted first, and the outer bar has to grow with it — otherwise first
// run counts one flow while the screen inside it counts another.
LaunchedEffect(accountState.totalSteps) {
viewModel.onAccountStepsChanged(accountState.totalSteps)
}
AddAccountScreen(
onDone = { viewModel.onAccountFinished(added = true) },
onBack = { viewModel.onAccountFinished(added = false) },
stepOffset = state.position - 1,
totalSteps = state.total,
viewModel = account,
)
}
@@ -0,0 +1,78 @@
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,
)
}
}
+52 -78
View File
@@ -16,29 +16,6 @@
<uses-permission android:name="org.tasks.permission.READ_TASKS" /> <uses-permission android:name="org.tasks.permission.READ_TASKS" />
<uses-permission android:name="org.tasks.permission.WRITE_TASKS" /> <uses-permission android:name="org.tasks.permission.WRITE_TASKS" />
<!-- CalDAV sync. ACCESS_NETWORK_STATE is merged in by work-runtime anyway,
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.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Exact due-time reminders. User-granted: USE_EXACT_ALARM is reserved for <!-- Exact due-time reminders. User-granted: USE_EXACT_ALARM is reserved for
@@ -55,12 +32,6 @@
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent> </intent>
<!-- Custom Tabs provider detection. Without this entry it silently finds
nothing on API 30+, and the Nextcloud login flow falls back to an
external browser for no visible reason. -->
<intent>
<action android:name="android.support.customtabs.action.CustomTabsService" />
</intent>
</queries> </queries>
<application <application
@@ -71,7 +42,6 @@
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:localeConfig="@xml/locales_config" android:localeConfig="@xml/locales_config"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Agendula" android:theme="@style/Theme.Agendula"
@@ -185,6 +155,58 @@
android:resource="@xml/appwidget_info_tasks" /> android:resource="@xml/appwidget_info_tasks" />
</receiver> </receiver>
<!-- "Today" home-screen widget (Glance): a progress ring for today's tasks. -->
<receiver
android:name=".widget.TodayWidgetReceiver"
android:label="@string/widget_today_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info_today" />
</receiver>
<!-- "Up next" home-screen widget (Glance): the single nearest due task. -->
<receiver
android:name=".widget.UpNextWidgetReceiver"
android:label="@string/widget_up_next_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info_up_next" />
</receiver>
<!-- "Lists" home-screen widget (Glance): one shortcut tile per list. -->
<receiver
android:name=".widget.ListsWidgetReceiver"
android:label="@string/widget_lists_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info_lists" />
</receiver>
<!-- "Week" home-screen widget (Glance): a seven-day strip of task counts. -->
<receiver
android:name=".widget.WeekWidgetReceiver"
android:label="@string/widget_week_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info_week" />
</receiver>
<!-- Per-widget list picker, launched by the host on placement / reconfigure. --> <!-- Per-widget list picker, launched by the host on placement / reconfigure. -->
<activity <activity
android:name=".widget.TaskWidgetConfigActivity" android:name=".widget.TaskWidgetConfigActivity"
@@ -211,54 +233,6 @@
</intent-filter> </intent-filter>
</receiver> </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 <!-- WorkManager's on-demand initialisation. Removing the default
initializer is what lets AgendulaApp supply a HiltWorkerFactory, so initializer is what lets AgendulaApp supply a HiltWorkerFactory, so
@HiltWorker workers can take injected dependencies. --> @HiltWorker workers can take injected dependencies. -->
@@ -10,10 +10,10 @@ import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.agendula.data.di.ApplicationScope import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import de.jeanlucmakiola.agendula.data.di.ChannelRefresher
import de.jeanlucmakiola.agendula.data.reminders.ReminderMaintenanceWorker import de.jeanlucmakiola.agendula.data.reminders.ReminderMaintenanceWorker
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
import de.jeanlucmakiola.agendula.data.reminders.TaskNotifier import de.jeanlucmakiola.agendula.data.reminders.TaskNotifier
import de.jeanlucmakiola.agendula.data.sync.SyncNoticeNotifier
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StartupGate import de.jeanlucmakiola.agendula.data.tasks.StartupGate
import de.jeanlucmakiola.agendula.data.tasks.room.DatabaseCheckpoint 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) val entryPoint = EntryPointAccessors.fromApplication(this, AppEntryPoint::class.java)
runCatching { runCatching {
entryPoint.taskNotifier().refreshChannel() entryPoint.taskNotifier().refreshChannel()
entryPoint.syncNoticeNotifier().refreshChannel() entryPoint.channelRefreshers().forEach { it.refreshChannel() }
} }
} }
@@ -120,7 +120,7 @@ class AgendulaApp : Application(), Configuration.Provider {
fun applicationScope(): CoroutineScope fun applicationScope(): CoroutineScope
fun databaseCheckpoint(): DatabaseCheckpoint fun databaseCheckpoint(): DatabaseCheckpoint
fun taskNotifier(): TaskNotifier fun taskNotifier(): TaskNotifier
fun syncNoticeNotifier(): SyncNoticeNotifier fun channelRefreshers(): Set<@JvmSuppressWildcards ChannelRefresher>
fun taskWidgetUpdater(): TaskWidgetUpdater fun taskWidgetUpdater(): TaskWidgetUpdater
} }
} }
@@ -25,7 +25,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.agendula.data.di.LaunchHook import de.jeanlucmakiola.agendula.data.di.LaunchHook
import de.jeanlucmakiola.agendula.data.sync.SyncNoticeNotifier
import de.jeanlucmakiola.agendula.domain.SmartList import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.ui.imports.importIntentUri import de.jeanlucmakiola.agendula.ui.imports.importIntentUri
import de.jeanlucmakiola.agendula.ui.navigation.AppShortcuts import de.jeanlucmakiola.agendula.ui.navigation.AppShortcuts
@@ -33,10 +32,6 @@ import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.agendula.ui.RootScreen import de.jeanlucmakiola.agendula.ui.RootScreen
import de.jeanlucmakiola.agendula.ui.crash.CrashReportActivity import de.jeanlucmakiola.agendula.ui.crash.CrashReportActivity
import de.jeanlucmakiola.agendula.ui.navigation.NavRequest import de.jeanlucmakiola.agendula.ui.navigation.NavRequest
import de.jeanlucmakiola.agendula.data.sync.AccountRepository
import de.jeanlucmakiola.agendula.data.sync.PendingLoginFlowStore
import de.jeanlucmakiola.agendula.data.sync.SyncTrigger
import de.jeanlucmakiola.agendula.data.sync.push.PushRegistrar
import de.jeanlucmakiola.agendula.ui.settings.SettingsViewModel import de.jeanlucmakiola.agendula.ui.settings.SettingsViewModel
import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme
import de.jeanlucmakiola.floret.crash.CrashReportDialog import de.jeanlucmakiola.floret.crash.CrashReportDialog
@@ -55,14 +50,6 @@ class MainActivity : ComponentActivity() {
@Inject lateinit var launchHooks: Set<@JvmSuppressWildcards LaunchHook> @Inject lateinit var launchHooks: Set<@JvmSuppressWildcards LaunchHook>
@Inject lateinit var accounts: AccountRepository
@Inject lateinit var syncTrigger: SyncTrigger
@Inject lateinit var pendingLoginFlows: PendingLoginFlowStore
@Inject lateinit var push: PushRegistrar
// A captured crash report awaiting the user's decision, surfaced as a dialog // A captured crash report awaiting the user's decision, surfaced as a dialog
// over the app on the next launch (the single-crash path). A startup // over the app on the next launch (the single-crash path). A startup
// crash-loop is handled out of band, before setContent — see below. // crash-loop is handled out of band, before setContent — see below.
@@ -94,34 +81,9 @@ class MainActivity : ComponentActivity() {
// Surface a single captured crash as a dialog on the next launch. // Surface a single captured crash as a dialog on the next launch.
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this) if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
// Sync hard on app open: the periodic worker's interval is a floor, and // Each hook on its own, so one waiting on the network holds up no other.
// in the `rare` and `restricted` App Standby buckets it may not have run
// at all. `KEEP` makes rescheduling idempotent, so this also repairs a
// schedule lost to "clear app data" or to a restore.
//
// ⚠️ Only on a genuine open. This activity declares no `configChanges`,
// so onCreate runs again on every rotation, theme switch, locale change
// and font-scale change — each of which would otherwise start a fresh
// network sync the moment the previous one finished.
if (savedInstanceState == null) { if (savedInstanceState == null) {
lifecycleScope.launch { launchHooks.forEach { hook -> lifecycleScope.launch { runCatching { hook.onLaunch(intent) } } }
runCatching {
accounts.rescheduleAll()
accounts.syncable().forEach { syncTrigger.enqueue(it.displayName) }
// A login flow the previous process died in the middle of.
// Its password, if the user approved, exists nowhere else.
pendingLoginFlows.reclaim()
}
// Last and on its own: it waits on the network, and must not
// hold up the reclaim above. Re-registering on open is what
// the connector recommends.
runCatching { push.updateAll() }
}
}
// Variant hooks: the demo seeder in debug builds, nothing in release.
if (savedInstanceState == null) {
lifecycleScope.launch { launchHooks.forEach { runCatching { it.onLaunch(intent) } } }
} }
setContent { setContent {
val settingsViewModel: SettingsViewModel = hiltViewModel() val settingsViewModel: SettingsViewModel = hiltViewModel()
@@ -180,12 +142,16 @@ class MainActivity : ComponentActivity() {
const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID" const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID"
const val EXTRA_OCCURRENCE_START = "de.jeanlucmakiola.agendula.extra.OCCURRENCE_START" const val EXTRA_OCCURRENCE_START = "de.jeanlucmakiola.agendula.extra.OCCURRENCE_START"
private const val EXTRA_OPEN_ACCOUNTS = "de.jeanlucmakiola.agendula.extra.OPEN_ACCOUNTS" private const val EXTRA_OPEN_ACCOUNTS = "de.jeanlucmakiola.agendula.extra.OPEN_ACCOUNTS"
/** The account a sign-in notification is about, on the intent it opens. */
const val EXTRA_SIGN_IN_ACCOUNT_ID = "de.jeanlucmakiola.agendula.extra.SIGN_IN_ACCOUNT_ID"
const val ACTION_NEW_TASK = "de.jeanlucmakiola.agendula.action.NEW_TASK" const val ACTION_NEW_TASK = "de.jeanlucmakiola.agendula.action.NEW_TASK"
const val ACTION_TODAY = "de.jeanlucmakiola.agendula.action.TODAY" const val ACTION_TODAY = "de.jeanlucmakiola.agendula.action.TODAY"
private const val ACTION_OPEN_SMART = "de.jeanlucmakiola.agendula.action.OPEN_SMART" private const val ACTION_OPEN_SMART = "de.jeanlucmakiola.agendula.action.OPEN_SMART"
private const val ACTION_OPEN_LIST = "de.jeanlucmakiola.agendula.action.OPEN_LIST" private const val ACTION_OPEN_LIST = "de.jeanlucmakiola.agendula.action.OPEN_LIST"
private const val EXTRA_SMART_LIST = "de.jeanlucmakiola.agendula.extra.SMART_LIST" private const val EXTRA_SMART_LIST = "de.jeanlucmakiola.agendula.extra.SMART_LIST"
private const val EXTRA_LIST_ID = "de.jeanlucmakiola.agendula.extra.LIST_ID" private const val EXTRA_LIST_ID = "de.jeanlucmakiola.agendula.extra.LIST_ID"
private const val EXTRA_PRESET_LIST_ID = "de.jeanlucmakiola.agendula.extra.PRESET_LIST_ID"
private const val SHARED_TITLE_LIMIT = 500 private const val SHARED_TITLE_LIMIT = 500
private const val NO_OCCURRENCE = -1L private const val NO_OCCURRENCE = -1L
@@ -206,9 +172,14 @@ class MainActivity : ComponentActivity() {
.putExtra(EXTRA_OPEN_ACCOUNTS, true) .putExtra(EXTRA_OPEN_ACCOUNTS, true)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
/** The launcher shortcut's and Quick Settings tile's "New task". */ /**
fun newTaskIntent(context: Context): Intent = * The launcher shortcut's, Quick Settings tile's and a widget's "New
Intent(ACTION_NEW_TASK, null, context, MainActivity::class.java) * task". [listId] preset the list when it names exactly one; the data
* URI keeps each widget's PendingIntent apart from the others.
*/
fun newTaskIntent(context: Context, listId: Long? = null): Intent =
Intent(ACTION_NEW_TASK, listId?.let { "agendula://newtask/$it".toUri() }, context, MainActivity::class.java)
.putExtra(EXTRA_PRESET_LIST_ID, listId ?: -1L)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
fun todayIntent(context: Context): Intent = fun todayIntent(context: Context): Intent =
@@ -229,11 +200,15 @@ class MainActivity : ComponentActivity() {
internal fun navRequestOf(intent: Intent?): NavRequest? { internal fun navRequestOf(intent: Intent?): NavRequest? {
if (intent == null) return null if (intent == null) return null
intent.getLongExtra(SyncNoticeNotifier.EXTRA_SIGN_IN_ACCOUNT_ID, -1L).takeIf { it > 0L } if (BuildConfig.SYNC_ENABLED) {
?.let { return NavRequest.OpenAccount(it) } intent.getLongExtra(EXTRA_SIGN_IN_ACCOUNT_ID, -1L).takeIf { it > 0L }
if (intent.getBooleanExtra(EXTRA_OPEN_ACCOUNTS, false)) return NavRequest.OpenAccounts ?.let { return NavRequest.OpenAccount(it) }
if (intent.getBooleanExtra(EXTRA_OPEN_ACCOUNTS, false)) return NavRequest.OpenAccounts
}
when (intent.action) { when (intent.action) {
ACTION_NEW_TASK -> return NavRequest.NewTask() ACTION_NEW_TASK -> return NavRequest.NewTask(
listId = intent.getLongExtra(EXTRA_PRESET_LIST_ID, -1L).takeIf { it > 0L },
)
ACTION_TODAY -> return NavRequest.OpenSmart(SmartList.TODAY) ACTION_TODAY -> return NavRequest.OpenSmart(SmartList.TODAY)
ACTION_OPEN_SMART -> intent.getStringExtra(EXTRA_SMART_LIST) ACTION_OPEN_SMART -> intent.getStringExtra(EXTRA_SMART_LIST)
?.let { name -> SmartList.entries.firstOrNull { it.name == name } } ?.let { name -> SmartList.entries.firstOrNull { it.name == name } }
@@ -0,0 +1,22 @@
package de.jeanlucmakiola.agendula.data.di
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.Multibinds
/**
* A notification channel a build variant adds, re-created in the current
* language on start and on a configuration change. The `full` flavor
* contributes the sync notices; `offline` has none.
*/
fun interface ChannelRefresher {
fun refreshChannel()
}
@Module
@InstallIn(SingletonComponent::class)
abstract class ChannelRefresherModule {
@Multibinds
abstract fun channelRefreshers(): Set<ChannelRefresher>
}
@@ -12,13 +12,6 @@ import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.agendula.data.sync.AccountCreator
import de.jeanlucmakiola.agendula.data.sync.AccountRepository
import de.jeanlucmakiola.agendula.data.sync.CalDavGateway
import de.jeanlucmakiola.agendula.data.sync.LoginFlowRecord
import de.jeanlucmakiola.agendula.data.sync.PendingLoginFlowStore
import de.jeanlucmakiola.agendula.data.sync.SyncOnEdit
import de.jeanlucmakiola.agendula.data.sync.OkHttpCalDavGateway
import de.jeanlucmakiola.agendula.data.tasks.AndroidProviderEnvironment import de.jeanlucmakiola.agendula.data.tasks.AndroidProviderEnvironment
import de.jeanlucmakiola.agendula.data.tasks.AndroidTasksDataSource import de.jeanlucmakiola.agendula.data.tasks.AndroidTasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.ModeRoutingTasksDataSource import de.jeanlucmakiola.agendula.data.tasks.ModeRoutingTasksDataSource
@@ -27,7 +20,6 @@ import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.TasksRepositoryImpl import de.jeanlucmakiola.agendula.data.tasks.TasksRepositoryImpl
import de.jeanlucmakiola.agendula.data.tasks.room.LocalWriteListener
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
@@ -51,37 +43,13 @@ import javax.inject.Singleton
* reconciled", and the credential store to accounts that ask to be signed in * reconciled", and the credential store to accounts that ask to be signed in
* again — all states the app already knows how to be in, unlike a launch loop. * again — all states the app already knows how to be in, unlike a launch loop.
*/ */
private fun replaceCorrupted() = ReplaceFileCorruptionHandler { emptyPreferences() } internal fun replaceCorrupted() = ReplaceFileCorruptionHandler { emptyPreferences() }
private val Context.agendulaDataStore: DataStore<Preferences> by preferencesDataStore( private val Context.agendulaDataStore: DataStore<Preferences> by preferencesDataStore(
name = "agendula_prefs", name = "agendula_prefs",
corruptionHandler = replaceCorrupted(), corruptionHandler = replaceCorrupted(),
) )
/** See [CredentialsDataStore] for why this is a separate file. */
private val Context.credentialsDataStore: DataStore<Preferences> by preferencesDataStore(
name = CREDENTIALS_DATASTORE,
corruptionHandler = replaceCorrupted(),
)
/** See [SyncStateDataStore] for why this is a separate file. */
private val Context.syncStateDataStore: DataStore<Preferences> by preferencesDataStore(
name = SYNC_STATE_DATASTORE,
corruptionHandler = replaceCorrupted(),
)
/**
* Named here and in `backup_rules.xml` / `data_extraction_rules.xml`, which
* exclude `datastore/$CREDENTIALS_DATASTORE.preferences_pb` by this name.
*/
const val CREDENTIALS_DATASTORE = "agendula_credentials"
/**
* Named here and in `backup_rules.xml` / `data_extraction_rules.xml`, which
* exclude `datastore/$SYNC_STATE_DATASTORE.preferences_pb` by this name.
*/
const val SYNC_STATE_DATASTORE = "agendula_sync_state"
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
abstract class DataBindModule { abstract class DataBindModule {
@@ -94,22 +62,6 @@ abstract class DataBindModule {
@Singleton @Singleton
abstract fun bindProviderEnvironment(impl: AndroidProviderEnvironment): ProviderEnvironment abstract fun bindProviderEnvironment(impl: AndroidProviderEnvironment): ProviderEnvironment
@Binds
@Singleton
abstract fun bindCalDavGateway(impl: OkHttpCalDavGateway): CalDavGateway
@Binds
@Singleton
abstract fun bindAccountCreator(impl: AccountRepository): AccountCreator
@Binds
@Singleton
abstract fun bindLoginFlowRecord(impl: PendingLoginFlowStore): LoginFlowRecord
@Binds
@Singleton
abstract fun bindLocalWriteListener(impl: SyncOnEdit): LocalWriteListener
// Deliberately unqualified-free of the routing above: this is the external // Deliberately unqualified-free of the routing above: this is the external
// store itself, for the one caller that has to read it while another store is // store itself, for the one caller that has to read it while another store is
// the active one. // the active one.
@@ -128,18 +80,6 @@ object DataProvideModule {
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> = fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
context.agendulaDataStore context.agendulaDataStore
@Provides
@Singleton
@CredentialsDataStore
fun provideCredentialsDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
context.credentialsDataStore
@Provides
@Singleton
@SyncStateDataStore
fun provideSyncStateDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
context.syncStateDataStore
@Provides @Provides
@Singleton @Singleton
fun provideTasksDatabase(@ApplicationContext context: Context): TasksDatabase = fun provideTasksDatabase(@ApplicationContext context: Context): TasksDatabase =
@@ -149,6 +89,7 @@ object DataProvideModule {
// backed-up `.db` does not. The backup rules carry all three files and // backed-up `.db` does not. The backup rules carry all three files and
// the app checkpoints on ON_STOP. // the app checkpoints on ON_STOP.
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING) .setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
.addMigrations(TasksDatabase.MIGRATION_1_2)
.build() .build()
/** /**
@@ -7,9 +7,8 @@ import dagger.hilt.components.SingletonComponent
import dagger.multibindings.Multibinds import dagger.multibindings.Multibinds
/** /**
* Something a build variant wants to run when the app is launched with [Intent]. * Something a build variant runs on a genuine app open (not on a configuration
* Release builds contribute none; the debug source set adds the demo seeder, so * change): sync in the `full` flavor, the demo seeder in debug builds.
* that class never ships.
*/ */
fun interface LaunchHook { fun interface LaunchHook {
suspend fun onLaunch(intent: Intent) suspend fun onLaunch(intent: Intent)
@@ -17,37 +17,6 @@ annotation class IoDispatcher
@Retention(AnnotationRetention.BINARY) @Retention(AnnotationRetention.BINARY)
annotation class ApplicationScope annotation class ApplicationScope
/**
* Marks the DataStore holding **only** the Keystore-encrypted app passwords.
*
* A separate file from `agendula_prefs` on purpose: Auto Backup includes
* `datastore/`, and a restored ciphertext is permanently undecryptable because
* Keystore keys are non-exportable. Its own file is what lets the backup rules
* exclude the credentials and nothing else — excluding the whole database or
* all of DataStore would trade a latent bug for a live one.
*/
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class CredentialsDataStore
/**
* Marks the DataStore holding per-device **sync bookkeeping** — the quarantine
* counters and the full-reconciliation clock.
*
* Its own file for the same reason the credentials have one: Auto Backup
* includes `datastore/`, and every value in here is a statement about *this*
* device's conversation with a server. Restored onto a new install they are all
* lies, and two of them are dangerous — a restored "reconciled recently" makes
* the engine trust a sync token for another day, which is precisely the silently
* pruned change log the full path exists to catch, and a restored quarantine
* count silently skips resources that were never tried here.
*
* Not user data, so nothing is lost by excluding it.
*/
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class SyncStateDataStore
/** /**
* Marks the **external** provider's [de.jeanlucmakiola.agendula.data.tasks * Marks the **external** provider's [de.jeanlucmakiola.agendula.data.tasks
* .TasksDataSource] — the OpenTasks/tasks.org path specifically, rather than * .TasksDataSource] — the OpenTasks/tasks.org path specifically, rather than
@@ -95,6 +95,8 @@ data class Settings(
val pushEnabled: Boolean = true, val pushEnabled: Boolean = true,
/** Lists of the current store whose tasks the smart lists (and their counts) leave out. */ /** Lists of the current store whose tasks the smart lists (and their counts) leave out. */
val hiddenFromSmartLists: Set<Long> = emptySet(), val hiddenFromSmartLists: Set<Long> = emptySet(),
/** Pre-fill a new task's start with today's date. */
val defaultStartToday: Boolean = false,
) { ) {
/** [tasks] as [filter] shows them: a smart list drops the lists kept out of it. */ /** [tasks] as [filter] shows them: a smart list drops the lists kept out of it. */
fun visibleIn(filter: TaskFilter, tasks: List<Task>): List<Task> = fun visibleIn(filter: TaskFilter, tasks: List<Task>): List<Task> =
@@ -153,6 +155,7 @@ class SettingsPrefs @Inject constructor(
.mapNotNull { entry -> entry.substringAfter("$mode:", "").toLongOrNull() } .mapNotNull { entry -> entry.substringAfter("$mode:", "").toLongOrNull() }
.toSet() .toSet()
}, },
defaultStartToday = p[DEFAULT_START_TODAY] ?: false,
) )
} }
@@ -166,6 +169,8 @@ class SettingsPrefs @Inject constructor(
suspend fun setAutofocusTitle(enabled: Boolean) = dataStore.edit { it[AUTOFOCUS_TITLE] = enabled } suspend fun setAutofocusTitle(enabled: Boolean) = dataStore.edit { it[AUTOFOCUS_TITLE] = enabled }
suspend fun setDefaultStartToday(enabled: Boolean) = dataStore.edit { it[DEFAULT_START_TODAY] = enabled }
suspend fun setTimeFormat(pref: TimeFormatPref) = dataStore.edit { it[TIME_FORMAT] = pref.name } suspend fun setTimeFormat(pref: TimeFormatPref) = dataStore.edit { it[TIME_FORMAT] = pref.name }
suspend fun setWeekStart(day: DayOfWeek?) = dataStore.edit { suspend fun setWeekStart(day: DayOfWeek?) = dataStore.edit {
@@ -282,6 +287,7 @@ class SettingsPrefs @Inject constructor(
val SYNC_INTERVAL = intPreferencesKey("sync_interval_minutes") val SYNC_INTERVAL = intPreferencesKey("sync_interval_minutes")
val PUSH_ENABLED = booleanPreferencesKey("push_enabled") val PUSH_ENABLED = booleanPreferencesKey("push_enabled")
val SMART_LIST_HIDDEN = stringSetPreferencesKey("smart_list_hidden") val SMART_LIST_HIDDEN = stringSetPreferencesKey("smart_list_hidden")
val DEFAULT_START_TODAY = booleanPreferencesKey("default_start_today")
} }
} }
@@ -0,0 +1,52 @@
package de.jeanlucmakiola.agendula.data.sync
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
/**
* Task lists that live on a server: making, renaming, recolouring and deleting
* them. The `full` flavor talks CalDAV; the `offline` one has no accounts, so
* every list is device-only and none of this is ever reached with a real one.
*/
interface RemoteLists {
/** Why a collection write did not happen, in a form the UI can translate. */
sealed interface Outcome {
data object Done : Outcome
/** The server said no, and will say no again. */
data class Refused(val code: Int) : Outcome
/** The server could not be reached. Worth another try. */
data object Unreachable : Outcome
/** This account cannot make collections at all — iCloud, Posteo, Google. */
data object Unsupported : Outcome
/** Ours is a read-only share; the write belongs to whoever owns it. */
data object ReadOnly : Outcome
/** The account is gone, stopped, or has no credential we can decrypt. */
data object NoAccount : Outcome
/**
* The server answered something this call cannot make sense of.
*
* ⚠️ Not [Unreachable]. `CollectionOutcome` is one type across create,
* update and delete, so each of them has branches the other's method
* can return and its own cannot — and mapping those to [Unreachable]
* told someone sitting on wifi that they were offline. Unreachable is a
* claim about the network, and this is not one.
*/
data object Unexpected : Outcome
}
/** The accounts a new list may be created on. */
suspend fun creatableAccounts(): List<AccountEntity>
/** Makes a collection on [accountId]'s home set and a row pointing at it. */
suspend fun create(accountId: Long, name: String, color: Int): Outcome
suspend fun rename(listId: Long, name: String, color: Int): Outcome
suspend fun delete(listId: Long): Outcome
}
@@ -0,0 +1,6 @@
package de.jeanlucmakiola.agendula.data.sync
/** Asks for an account to be synced now. A no-op in the `offline` flavor. */
fun interface SyncRequests {
fun syncNow(accountName: String)
}
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.agendula.data.tasks package de.jeanlucmakiola.agendula.data.tasks
import android.content.ContentProviderOperation
import android.content.ContentResolver import android.content.ContentResolver
import android.content.ContentUris import android.content.ContentUris
import android.content.ContentValues import android.content.ContentValues
@@ -19,6 +20,7 @@ import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.export.ExportTask import de.jeanlucmakiola.agendula.domain.export.ExportTask
import de.jeanlucmakiola.agendula.domain.normalizeTags
import de.jeanlucmakiola.agendula.domain.recurrence.ExpansionWindow import de.jeanlucmakiola.agendula.domain.recurrence.ExpansionWindow
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
@@ -90,11 +92,14 @@ class AndroidTasksDataSource @Inject constructor(
// `is_recurring` / `distance_from_current`). The by-name mapper reads // `is_recurring` / `distance_from_current`). The by-name mapper reads
// whatever is present and nulls the rest; the repository re-sorts, so we // whatever is present and nulls the rest; the repository re-sorts, so we
// don't depend on a provider sort column either. // don't depend on a provider sort column either.
return resolver.query(uri, null, selection, args, null) val tasks = resolver.query(uri, null, selection, args, null)
?.use { c -> ?.use { c ->
val reader = CursorColumnReader(c) val reader = CursorColumnReader(c)
buildList { while (c.moveToNext()) add(TaskMapper.task(reader)) } buildList { while (c.moveToNext()) add(TaskMapper.task(reader)) }
} ?: emptyList() } ?: emptyList()
if (tasks.isEmpty()) return tasks
val tags = categories(tasks.map { it.taskId })
return tasks.map { task -> tags[task.taskId]?.let { task.copy(tags = it) } ?: task }
} }
override fun exportTasks(listId: Long): List<ExportTask> { override fun exportTasks(listId: Long): List<ExportTask> {
@@ -102,7 +107,7 @@ class AndroidTasksDataSource @Inject constructor(
// table's shape varies across provider versions, and the by-name mapper // table's shape varies across provider versions, and the by-name mapper
// reads what's there. // reads what's there.
val uri = TasksContract.tasksUri(authority()) val uri = TasksContract.tasksUri(authority())
return resolver.query( val tasks = resolver.query(
uri, uri,
null, null,
// _deleted marks a row awaiting a sync round-trip. It's gone as far as // _deleted marks a row awaiting a sync round-trip. It's gone as far as
@@ -115,21 +120,39 @@ class AndroidTasksDataSource @Inject constructor(
val reader = CursorColumnReader(c) val reader = CursorColumnReader(c)
buildList { while (c.moveToNext()) add(TaskMapper.exportTask(reader)) } buildList { while (c.moveToNext()) add(TaskMapper.exportTask(reader)) }
} ?: emptyList() } ?: emptyList()
if (tasks.isEmpty()) return tasks
val tags = categories(tasks.map { it.taskId })
return tasks.map { task -> tags[task.taskId]?.let { task.copy(tags = it) } ?: task }
} }
// --- writes --------------------------------------------------------------- // --- writes ---------------------------------------------------------------
override fun insertTask(form: TaskForm): Long { override fun insertTask(form: TaskForm): Long {
val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id) val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id)
val uri = resolver.insert(TasksContract.tasksUri(authority()), values.toContentValues()) // One batch, so a refused tag leaves no task behind for a retry to duplicate.
?: throw TaskWriteFailedException("insert task") val authority = authority()
return uri.lastPathSegment?.toLongOrNull() ?: throw TaskWriteFailedException("insert task: no id") 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")
} }
override fun updateTask(taskId: Long, form: TaskForm) { override fun updateTask(taskId: Long, form: TaskForm) {
val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id) val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id)
val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null) val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("update task $taskId") if (rows == 0) throw TaskWriteFailedException("update task $taskId")
setCategories(taskId, form.tags)
} }
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) { override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) {
@@ -151,6 +174,7 @@ class AndroidTasksDataSource @Inject constructor(
(Tasks.DUE to shifted(anchors?.second, occurrence?.due, form.due)) (Tasks.DUE to shifted(anchors?.second, occurrence?.due, form.due))
val rows = resolver.update(taskUri(authority(), seriesId), values.toContentValues(), null, null) val rows = resolver.update(taskUri(authority(), seriesId), values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("update series $seriesId") if (rows == 0) throw TaskWriteFailedException("update series $seriesId")
setCategories(seriesId, form.tags)
} }
/** /**
@@ -243,8 +267,28 @@ class AndroidTasksDataSource @Inject constructor(
?: throw TaskWriteFailedException("update instance $taskId@$occurrenceStart: no such occurrence") ?: throw TaskWriteFailedException("update instance $taskId@$occurrenceStart: no such occurrence")
val values = TaskWriteMapper.instanceValues(form, ZoneId.systemDefault().id) val values = TaskWriteMapper.instanceValues(form, ZoneId.systemDefault().id)
val uri = TasksContract.instanceUri(authority(), instanceId) val uri = TasksContract.instanceUri(authority(), instanceId)
val rows = resolver.update(uri, values.toContentValues(), null, null) // The fork copies the series' properties onto the override, so the tags
if (rows == 0) throw TaskWriteFailedException("update instance $instanceId") // go onto the series first and are put back once the override exists.
// Unchanged tags are inherited as they are, and the series is left alone.
val seriesTags = categories(listOf(taskId))[taskId].orEmpty()
val retag = normalizeTags(form.tags) != seriesTags
if (retag) setCategories(taskId, form.tags)
var failure: Throwable? = null
try {
val rows = resolver.update(uri, values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("update instance $instanceId")
} catch (e: Throwable) {
failure = e
throw e
} finally {
if (retag) {
try {
setCategories(taskId, seriesTags)
} catch (e: Throwable) {
failure?.addSuppressed(e) ?: throw e
}
}
}
} }
/** /**
@@ -348,6 +392,58 @@ class AndroidTasksDataSource @Inject constructor(
} ?: emptyList() } ?: emptyList()
} }
/** Tags by task id for [taskIds], in stored order. */
private fun categories(taskIds: Collection<Long>): Map<Long, List<String>> {
val rows = taskIds.distinct().chunked(MAX_SQL_ARGS - 1).flatMap { chunk ->
val selection = "${Properties.MIMETYPE} = ? AND ${Properties.TASK_ID} IN (${chunk.joinToString(",") { "?" }})"
val args = (listOf(TasksContract.Category.MIMETYPE) + chunk.map(Long::toString)).toTypedArray()
resolver.query(
TasksContract.propertiesUri(authority()),
arrayOf(Properties.TASK_ID, TasksContract.Category.NAME),
selection,
args,
Properties.PROPERTY_ID,
)?.use { c ->
val reader = CursorColumnReader(c)
buildList {
while (c.moveToNext()) {
val id = reader.getLong(Properties.TASK_ID) ?: continue
val name = reader.getString(TasksContract.Category.NAME) ?: continue
add(id to name)
}
}
} ?: emptyList()
}
return rows.groupBy({ it.first }, { it.second }).mapValues { normalizeTags(it.value) }
}
/**
* Replaces [taskId]'s category rows in one batch, so a refused insert cannot
* leave half the tags, and leaves them alone when nothing changed so the task
* isn't dirtied.
*/
private fun setCategories(taskId: Long, tags: List<String>) {
val wanted = normalizeTags(tags)
if (categories(listOf(taskId))[taskId].orEmpty() == wanted) return
val authority = authority()
val uri = TasksContract.propertiesUri(authority)
val ops = arrayListOf(
ContentProviderOperation.newDelete(uri)
.withSelection(
"${Properties.TASK_ID} = ? AND ${Properties.MIMETYPE} = ?",
arrayOf(taskId.toString(), TasksContract.Category.MIMETYPE),
)
.build(),
)
wanted.forEach {
ops += ContentProviderOperation.newInsert(uri)
.withValues(TaskWriteMapper.categoryValues(taskId, it).toContentValues())
.build()
}
runCatching { resolver.applyBatch(authority, ops) }
.getOrElse { throw TaskWriteFailedException("set categories for task $taskId", it) }
}
override fun setCancelled(taskId: Long, cancelled: Boolean) { override fun setCancelled(taskId: Long, cancelled: Boolean) {
val rows = resolver.update(taskUri(authority(), taskId), cancelValues(cancelled).toContentValues(), null, null) val rows = resolver.update(taskUri(authority(), taskId), cancelValues(cancelled).toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("cancel task $taskId") if (rows == 0) throw TaskWriteFailedException("cancel task $taskId")
@@ -460,3 +556,6 @@ class AndroidTasksDataSource @Inject constructor(
return cv return cv
} }
} }
/** SQLite's default bound-argument limit on older Android releases. */
private const val MAX_SQL_ARGS = 999
@@ -5,8 +5,8 @@ class ProviderUnavailableException :
IllegalStateException("No tasks provider installed") IllegalStateException("No tasks provider installed")
/** A ContentResolver write returned no URI or affected no rows. */ /** A ContentResolver write returned no URI or affected no rows. */
class TaskWriteFailedException(operation: String) : class TaskWriteFailedException(operation: String, cause: Throwable? = null) :
RuntimeException("Task write failed: $operation") RuntimeException("Task write failed: $operation", cause)
/** /**
* The task changed (e.g. a DAVx5 sync or another app) since the edit form loaded * The task changed (e.g. a DAVx5 sync or another app) since the edit form loaded
@@ -113,6 +113,12 @@ object TaskWriteMapper {
Alarm.ALARM_TYPE to Alarm.TYPE_MESSAGE, Alarm.ALARM_TYPE to Alarm.TYPE_MESSAGE,
) )
fun categoryValues(taskId: Long, name: String): Map<String, Any?> = mapOf(
Properties.TASK_ID to taskId,
Properties.MIMETYPE to TasksContract.Category.MIMETYPE,
TasksContract.Category.NAME to name,
)
/** The user-owned columns of a list — what an edit is allowed to change. */ /** The user-owned columns of a list — what an edit is allowed to change. */
fun listValues(name: String, color: Int): Map<String, Any?> = mapOf( fun listValues(name: String, color: Int): Map<String, Any?> = mapOf(
Lists.NAME to name.trim(), Lists.NAME to name.trim(),
@@ -154,6 +154,18 @@ object TasksContract {
const val TYPE_MESSAGE = 1 const val TYPE_MESSAGE = 1
} }
/**
* A category property row — one tag. DAVx5 maps these to and from
* `CATEGORIES`. Inserting with only [NAME] is enough: the provider looks the
* category up in the task's account, or creates it, and fills in `data0`.
*/
object Category {
const val MIMETYPE = "vnd.android.cursor.item/category"
/** `data1` — the tag as spelled. */
const val NAME = "data1"
}
fun propertiesUri(authority: String): Uri = "content://$authority/${Properties.PATH}".toUri() fun propertiesUri(authority: String): Uri = "content://$authority/${Properties.PATH}".toUri()
// --- status values (TaskColumns.STATUS_*) -------------------------------- // --- status values (TaskColumns.STATUS_*) --------------------------------
@@ -63,6 +63,10 @@ class TasksRepositoryImpl @Inject constructor(
// parent's subtask-progress reflects ALL its children — completed ones // parent's subtask-progress reflects ALL its children — completed ones
// included — even on smart lists that hide completed tasks themselves. // included — even on smart lists that hide completed tasks themselves.
is TaskFilter.Smart -> TaskQuery(includeCompleted = true) is TaskFilter.Smart -> TaskQuery(includeCompleted = true)
// Same over-read as Smart: TaskQuery.listId only narrows to one list, so
// several lists are read in full and membership is filtered in-memory.
is TaskFilter.OfLists -> TaskQuery(includeCompleted = true)
is TaskFilter.Tagged -> TaskQuery(includeCompleted = true)
} }
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault()) val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
val all = SeriesCollapse.visible(dataSource.tasks(query)) val all = SeriesCollapse.visible(dataSource.tasks(query))
@@ -109,6 +113,7 @@ class TasksRepositoryImpl @Inject constructor(
parentId = parentId, parentId = parentId,
location = task.location, location = task.location,
url = task.url, url = task.url,
tags = task.tags,
), ),
) )
val reminders = dataSource.reminders()[task.taskId].orEmpty() val reminders = dataSource.reminders()[task.taskId].orEmpty()
@@ -9,6 +9,7 @@ import de.jeanlucmakiola.agendula.domain.ical.ICalParser
import de.jeanlucmakiola.agendula.domain.ical.ICalProperty import de.jeanlucmakiola.agendula.domain.ical.ICalProperty
import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer
import de.jeanlucmakiola.agendula.domain.ical.ICalValues import de.jeanlucmakiola.agendula.domain.ical.ICalValues
import de.jeanlucmakiola.agendula.domain.normalizeTags
import java.time.ZoneId import java.time.ZoneId
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
@@ -81,6 +82,8 @@ object VTodoMapper {
// Parameterised copies stay in the residue verbatim; authoring the // Parameterised copies stay in the residue verbatim; authoring the
// column beside them would emit the same exclusions twice. // column beside them would emit the same exclusions twice.
"RDATE", "EXDATE", "RDATE", "EXDATE",
// Several lines, or one the column would not re-serialise exactly.
"CATEGORIES",
) )
/** What a VTODO yields. Row identity ([TaskEntity.id], `listId`) is the caller's. */ /** What a VTODO yields. Row identity ([TaskEntity.id], `listId`) is the caller's. */
@@ -200,6 +203,9 @@ object VTodoMapper {
CLASS_NAMES.indexOf(classProperty?.value?.trim()?.uppercase()).takeIf { it >= 0 }, CLASS_NAMES.indexOf(classProperty?.value?.trim()?.uppercase()).takeIf { it >= 0 },
) )
val (categories, categoriesProperty) = readCategories(vtodo)
claim(categoriesProperty)
val sequenceProperty = vtodo.property("SEQUENCE") val sequenceProperty = vtodo.property("SEQUENCE")
val sequence = take( val sequence = take(
sequenceProperty, sequenceProperty,
@@ -225,6 +231,7 @@ object VTodoMapper {
title = take(vtodo.property("SUMMARY"), vtodo.property("SUMMARY")?.text()), title = take(vtodo.property("SUMMARY"), vtodo.property("SUMMARY")?.text()),
description = take(vtodo.property("DESCRIPTION"), vtodo.property("DESCRIPTION")?.text()), description = take(vtodo.property("DESCRIPTION"), vtodo.property("DESCRIPTION")?.text()),
location = take(vtodo.property("LOCATION"), vtodo.property("LOCATION")?.text()), location = take(vtodo.property("LOCATION"), vtodo.property("LOCATION")?.text()),
categories = categories,
// URI, not TEXT — escaping it would corrupt a query string. // URI, not TEXT — escaping it would corrupt a query string.
url = take(vtodo.property("URL"), vtodo.property("URL")?.value?.trim()), url = take(vtodo.property("URL"), vtodo.property("URL")?.value?.trim()),
status = status, status = status,
@@ -284,8 +291,9 @@ object VTodoMapper {
now: Instant = Clock.System.now(), now: Instant = Clock.System.now(),
): ICalComponent { ): ICalComponent {
val residue = parseResidue(entity.unknownProperties) val residue = parseResidue(entity.unknownProperties)
val residueCategories = normalizeTags(residue.properties("CATEGORIES").flatMap(::categoryValues))
val keptResidue = val keptResidue =
residue.properties.filterNot { contradictsResidue(it, residue, entity, parentUid) } residue.properties.filterNot { contradictsResidue(it, residue, residueCategories, entity, parentUid) }
val suppressed = keptResidue val suppressed = keptResidue
.map { it.name.uppercase() } .map { it.name.uppercase() }
.filterTo(mutableSetOf()) { it in SUPPRESSED_BY_RESIDUE } .filterTo(mutableSetOf()) { it in SUPPRESSED_BY_RESIDUE }
@@ -307,6 +315,7 @@ object VTodoMapper {
add("DESCRIPTION", entity.description?.let(ICalValues::escapeText)) add("DESCRIPTION", entity.description?.let(ICalValues::escapeText))
add("LOCATION", entity.location?.let(ICalValues::escapeText)) add("LOCATION", entity.location?.let(ICalValues::escapeText))
add("URL", entity.url) add("URL", entity.url)
add("CATEGORIES", normalizeTags(entity.categories).takeIf { it.isNotEmpty() }?.let(ICalValues::formatTextList))
add("STATUS", entity.status.toICalName()) add("STATUS", entity.status.toICalName())
add("PERCENT-COMPLETE", entity.percentComplete?.toString()) add("PERCENT-COMPLETE", entity.percentComplete?.toString())
@@ -386,6 +395,7 @@ object VTodoMapper {
private fun contradictsResidue( private fun contradictsResidue(
property: ICalProperty, property: ICalProperty,
residue: ICalComponent, residue: ICalComponent,
residueCategories: List<String>,
entity: TaskEntity, entity: TaskEntity,
parentUid: String?, parentUid: String?,
): Boolean { ): Boolean {
@@ -433,6 +443,7 @@ object VTodoMapper {
"PRIORITY" -> entity.priority != PRIORITY_NONE "PRIORITY" -> entity.priority != PRIORITY_NONE
"PERCENT-COMPLETE" -> entity.percentComplete != null "PERCENT-COMPLETE" -> entity.percentComplete != null
"CLASS" -> entity.classification != null "CLASS" -> entity.classification != null
"CATEGORIES" -> entity.categories != residueCategories
// Not SEQUENCE: it is the organiser's counter and never ours to bump, // Not SEQUENCE: it is the organiser's counter and never ours to bump,
// so the column stays at its fallback and the residue always wins. // so the column stays at its fallback and the residue always wins.
else -> false else -> false
@@ -475,6 +486,23 @@ object VTodoMapper {
// -------------------------------------------------------------- helpers // -------------------------------------------------------------- helpers
/**
* Every `CATEGORIES` copy's values, merged, and the one line to claim — only
* when it is the sole, parameterless copy and the column re-serialises it
* byte for byte. Anything else is read for display and stays in the residue.
*/
private fun readCategories(vtodo: ICalComponent): Pair<List<String>, ICalProperty?> {
val copies = vtodo.properties("CATEGORIES")
val values = normalizeTags(copies.flatMap(::categoryValues))
val claimable = copies.singleOrNull()?.takeIf {
it.params.isEmpty() && values.isNotEmpty() && ICalValues.formatTextList(values) == it.value
}
return values to claimable
}
private fun categoryValues(property: ICalProperty): List<String> =
normalizeTags(ICalValues.parseTextList(property.value))
private val CLASS_NAMES = listOf("PUBLIC", "PRIVATE", "CONFIDENTIAL") private val CLASS_NAMES = listOf("PUBLIC", "PRIVATE", "CONFIDENTIAL")
private class TimeRead( private class TimeRead(
@@ -15,6 +15,7 @@ import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
import de.jeanlucmakiola.agendula.domain.normalizeTags
import de.jeanlucmakiola.agendula.domain.statusFromInt import de.jeanlucmakiola.agendula.domain.statusFromInt
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -221,12 +222,17 @@ class OneShotImport @Inject constructor(
} }
val alarms = mutableListOf<LegacyAlarm>() val alarms = mutableListOf<LegacyAlarm>()
db.rawQuery("SELECT task_id, mimetype, data0, data1, data2 FROM Properties", null) val categories = mutableMapOf<Long, MutableList<String>>()
db.rawQuery("SELECT task_id, mimetype, data0, data1, data2 FROM Properties ORDER BY property_id", null)
.use { cursor -> .use { cursor ->
val r = CursorColumnReader(cursor) val r = CursorColumnReader(cursor)
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
if (r.getString("mimetype") != ALARM_MIMETYPE) continue
val taskId = r.getLong("task_id") ?: continue 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 minutes = r.getString("data0")?.trim()?.toIntOrNull() ?: continue val minutes = r.getString("data0")?.trim()?.toIntOrNull() ?: continue
alarms += LegacyAlarm( alarms += LegacyAlarm(
taskId = taskId, taskId = taskId,
@@ -241,7 +247,7 @@ class OneShotImport @Inject constructor(
} }
} }
return LegacySnapshot(lists, rows, alarms) return LegacySnapshot(lists, rows, alarms, categories.mapValues { normalizeTags(it.value) })
} }
// --- writing into Room ---------------------------------------------------- // --- writing into Room ----------------------------------------------------
@@ -276,7 +282,12 @@ class OneShotImport @Inject constructor(
// the unique index; a fresh uid costs the row nothing it still has. // 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)) } val uid = row.entity.uid.takeIf { seen.add(Triple(listId, it, recurrenceId)) }
?: UUID.randomUUID().toString() ?: UUID.randomUUID().toString()
val entity = row.entity.copy(listId = listId, uid = uid, recurrenceId = recurrenceId) val entity = row.entity.copy(
listId = listId,
uid = uid,
recurrenceId = recurrenceId,
categories = snapshot.categories[row.id].orEmpty(),
)
val newId = taskDao.insert(entity) val newId = taskDao.insert(entity)
taskIds[row.id] = newId taskIds[row.id] = newId
inserted += row to entity.copy(id = newId) inserted += row to entity.copy(id = newId)
@@ -379,6 +390,7 @@ class OneShotImport @Inject constructor(
const val ARCHIVE_NAME = "tasks.db.imported" const val ARCHIVE_NAME = "tasks.db.imported"
private const val ALARM_MIMETYPE = "vnd.android.cursor.item/alarm" 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 const val REFERENCE_START = "2"
private val SIDECARS = listOf("-journal", "-wal", "-shm") private val SIDECARS = listOf("-journal", "-wal", "-shm")
private val IMPORT_DONE = booleanPreferencesKey("legacy_import_done") private val IMPORT_DONE = booleanPreferencesKey("legacy_import_done")
@@ -391,6 +403,8 @@ private class LegacySnapshot(
val lists: List<LegacyList>, val lists: List<LegacyList>,
val tasks: List<LegacyTaskRow>, val tasks: List<LegacyTaskRow>,
val alarms: List<LegacyAlarm>, val alarms: List<LegacyAlarm>,
/** Tags by legacy task `_id`. */
val categories: Map<Long, List<String>>,
) )
private class LegacyList(val id: Long, val entity: TaskListEntity) private class LegacyList(val id: Long, val entity: TaskListEntity)
@@ -0,0 +1,44 @@
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,6 +2,8 @@ package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.TypeConverter import androidx.room.TypeConverter
import de.jeanlucmakiola.agendula.domain.TaskStatus 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.statusFromInt
import de.jeanlucmakiola.agendula.domain.toInt import de.jeanlucmakiola.agendula.domain.toInt
import kotlin.time.Instant import kotlin.time.Instant
@@ -36,3 +38,17 @@ object Converters {
fun alarmReferenceFrom(value: String): AlarmReference = fun alarmReferenceFrom(value: String): AlarmReference =
runCatching { AlarmReference.valueOf(value) }.getOrDefault(AlarmReference.DUE) 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,6 +5,7 @@ import androidx.room.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.Index import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import androidx.room.TypeConverters
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
import de.jeanlucmakiola.agendula.domain.TaskStatus import de.jeanlucmakiola.agendula.domain.TaskStatus
import kotlin.time.Instant import kotlin.time.Instant
@@ -71,7 +72,7 @@ data class TaskListEntity(
* *
* ⚠️ It was set by a rename and read by nobody. A collection's name and * ⚠️ It was set by a rename and read by nobody. A collection's name and
* colour are now written server-first by * colour are now written server-first by
* [de.jeanlucmakiola.agendula.data.sync.RemoteListRepository], so there is * [de.jeanlucmakiola.agendula.data.sync.RemoteLists], so there is
* no local edit left waiting to be pushed — and a flag that means "owed to * 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. * the server" while nothing ever pays it is worse than no flag at all.
*/ */
@@ -143,6 +144,10 @@ data class TaskEntity(
@ColumnInfo(name = "url") val url: String? = null, @ColumnInfo(name = "url") val url: String? = null,
/** ARGB override for the list colour. */ /** ARGB override for the list colour. */
@ColumnInfo(name = "color") val color: Int? = null, @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 // state
@ColumnInfo(name = "status", defaultValue = "0") val status: TaskStatus = TaskStatus.NEEDS_ACTION, @ColumnInfo(name = "status", defaultValue = "0") val status: TaskStatus = TaskStatus.NEEDS_ACTION,
@@ -49,6 +49,7 @@ object RoomTaskMapper {
description = row.task.description, description = row.task.description,
location = row.task.location, location = row.task.location,
url = row.task.url, url = row.task.url,
tags = row.task.categories,
priority = priorityFromICal(row.task.priority), priority = priorityFromICal(row.task.priority),
status = row.task.status, status = row.task.status,
percentComplete = row.task.percentComplete, percentComplete = row.task.percentComplete,
@@ -79,6 +80,7 @@ object RoomTaskMapper {
description = task.description, description = task.description,
location = task.location, location = task.location,
url = task.url, url = task.url,
tags = task.categories,
priority = priorityFromICal(task.priority), priority = priorityFromICal(task.priority),
status = task.status, status = task.status,
percentComplete = task.percentComplete, percentComplete = task.percentComplete,
@@ -520,7 +520,7 @@ class RoomTasksDataSource @Inject constructor(
/** /**
* ⚠️ Device-only lists. An account-backed collection is renamed through * ⚠️ Device-only lists. An account-backed collection is renamed through
* [de.jeanlucmakiola.agendula.data.sync.RemoteListRepository], which sends * [de.jeanlucmakiola.agendula.data.sync.RemoteLists], which sends
* the PROPPATCH first and writes here only once the server has taken it. * 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 * This used to set `is_dirty` for an account list instead, on the theory
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.agendula.data.tasks.room
import de.jeanlucmakiola.agendula.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskStatus import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.ical.ICalValues import de.jeanlucmakiola.agendula.domain.ical.ICalValues
import de.jeanlucmakiola.agendula.domain.normalizeTags
import de.jeanlucmakiola.agendula.domain.toICal import de.jeanlucmakiola.agendula.domain.toICal
import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@@ -50,6 +51,7 @@ object TaskFormWriter {
description = form.description?.trim()?.ifBlank { null }, description = form.description?.trim()?.ifBlank { null },
location = form.location?.trim()?.ifBlank { null }, location = form.location?.trim()?.ifBlank { null },
url = form.url?.trim()?.ifBlank { null }, url = form.url?.trim()?.ifBlank { null },
categories = normalizeTags(form.tags),
priority = form.priority.toICal(), priority = form.priority.toICal(),
percentComplete = percent, percentComplete = percent,
status = statusFor(percent, current.status), status = statusFor(percent, current.status),
@@ -3,6 +3,8 @@ package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.Database import androidx.room.Database
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.TypeConverters import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
/** /**
* Agendula's own task store. Four tables, designed from * Agendula's own task store. Four tables, designed from
@@ -18,7 +20,7 @@ import androidx.room.TypeConverters
TaskEntity::class, TaskEntity::class,
TaskAlarmEntity::class, TaskAlarmEntity::class,
], ],
version = 1, version = 2,
exportSchema = true, exportSchema = true,
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
@@ -30,5 +32,36 @@ abstract class TasksDatabase : RoomDatabase() {
companion object { companion object {
const val NAME = "agendula-tasks.db" 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,6 +16,7 @@ import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.export.ExportTask import de.jeanlucmakiola.agendula.domain.export.ExportTask
import de.jeanlucmakiola.agendula.domain.normalizeTags
import de.jeanlucmakiola.agendula.domain.toICal import de.jeanlucmakiola.agendula.domain.toICal
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -213,6 +214,7 @@ class ExternalImport @Inject constructor(
description = description, description = description,
location = location, location = location,
url = url, url = url,
categories = normalizeTags(tags),
status = status, status = status,
percentComplete = percentComplete, percentComplete = percentComplete,
completedAt = completedAt, completedAt = completedAt,
@@ -52,6 +52,8 @@ data class Task(
val description: String?, val description: String?,
val location: String?, val location: String?,
val url: String?, val url: String?,
/** `CATEGORIES`, as the task spells them. */
val tags: List<String> = emptyList(),
val priority: Priority, val priority: Priority,
val status: TaskStatus, val status: TaskStatus,
val percentComplete: Int?, val percentComplete: Int?,
@@ -161,3 +163,10 @@ fun TaskStatus.toInt(): Int = when (this) {
TaskStatus.COMPLETED -> ICalStatus.COMPLETED TaskStatus.COMPLETED -> ICalStatus.COMPLETED
TaskStatus.CANCELLED -> ICalStatus.CANCELLED 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,10 +5,14 @@ import kotlin.time.Instant
/** The built-in "smart" lists, computed from due dates rather than membership. */ /** The built-in "smart" lists, computed from due dates rather than membership. */
enum class SmartList { ALL, TODAY, UPCOMING, OVERDUE, NO_DATE, COMPLETED } enum class SmartList { ALL, TODAY, UPCOMING, OVERDUE, NO_DATE, COMPLETED }
/** What a task screen is showing: one real list, or a smart list. */ /** What a task screen is showing: one real list, a smart list, or a tag. */
sealed interface TaskFilter { sealed interface TaskFilter {
data class OfList(val listId: Long) : 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 data class Smart(val list: SmartList) : TaskFilter
/** Every task carrying [tag], open and completed, across all lists. */
data class Tagged(val tag: String) : TaskFilter
} }
/** /**
@@ -21,9 +25,14 @@ object TaskFiltering {
fun matches(task: Task, filter: TaskFilter, todayStart: Instant, todayEnd: Instant): Boolean = fun matches(task: Task, filter: TaskFilter, todayStart: Instant, todayEnd: Instant): Boolean =
when (filter) { when (filter) {
is TaskFilter.OfList -> task.listId == filter.listId 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.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 { private fun matchesSmart(task: Task, list: SmartList, todayStart: Instant, todayEnd: Instant): Boolean {
val due = task.due val due = task.due
return when (list) { return when (list) {
@@ -22,6 +22,7 @@ data class TaskForm(
val reminders: List<Int> = emptyList(), val reminders: List<Int> = emptyList(),
val location: String? = null, val location: String? = null,
val url: 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. */ /** The series' `RRULE` value; `null` = does not repeat. Ignored for a single-occurrence edit. */
val rrule: String? = null, val rrule: String? = null,
) { ) {
@@ -44,7 +45,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 * 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. * disclosure generic, so adding a field later is one entry plus its card.
*/ */
enum class TaskFormField { Description, Recurrence, Priority, Progress, Parent, Reminder, Location, Url } enum class TaskFormField { Description, Recurrence, Priority, Progress, Parent, Reminder, Location, Url, Tags }
/** The optional fields that already carry a value — auto-revealed when editing. */ /** The optional fields that already carry a value — auto-revealed when editing. */
fun TaskForm.populatedFields(): Set<TaskFormField> = buildSet { fun TaskForm.populatedFields(): Set<TaskFormField> = buildSet {
@@ -56,4 +57,5 @@ fun TaskForm.populatedFields(): Set<TaskFormField> = buildSet {
if (rrule != null) add(TaskFormField.Recurrence) if (rrule != null) add(TaskFormField.Recurrence)
if (!location.isNullOrBlank()) add(TaskFormField.Location) if (!location.isNullOrBlank()) add(TaskFormField.Location)
if (!url.isNullOrBlank()) add(TaskFormField.Url) if (!url.isNullOrBlank()) add(TaskFormField.Url)
if (tags.isNotEmpty()) add(TaskFormField.Tags)
} }
@@ -42,6 +42,7 @@ data class ExportTask(
val rdate: String?, val rdate: String?,
/** `tasks._id` of the parent, for `RELATED-TO;RELTYPE=PARENT`. */ /** `tasks._id` of the parent, for `RELATED-TO;RELTYPE=PARENT`. */
val parentId: Long?, val parentId: Long?,
val tags: List<String> = emptyList(),
) )
/** A task list and everything in it, ready to become one `.ics` document. */ /** A task list and everything in it, ready to become one `.ics` document. */
@@ -88,6 +88,7 @@ object ICalendarWriter {
task.location?.takeIf { it.isNotBlank() }?.let { property("LOCATION", it) } task.location?.takeIf { it.isNotBlank() }?.let { property("LOCATION", it) }
// URL is a URI, not TEXT: it must not be escaped like one. // URL is a URI, not TEXT: it must not be escaped like one.
task.url?.takeIf { it.isNotBlank() }?.let { rawProperty("URL", it) } 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.start?.let { dateProperty("DTSTART", it, task.isAllDay) }
task.due?.let { dateProperty("DUE", it, task.isAllDay) } task.due?.let { dateProperty("DUE", it, task.isAllDay) }

Some files were not shown because too many files have changed in this diff Show More