Compare commits

..
Author SHA1 Message Date
Jean-Luc Makiola 9b7f7f60d2 Pin the clock in the delete-following subtree test (#65)
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/65
2026-10-15 16:23:52 +02:00
Jean-Luc Makiolaandmakiolaj cff6308018 Let other apps write to the own store through the provider (#64)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped
Builds on the read-only provider (#62) and makes it writable for other apps, as long as Agendula uses its own store.

- `tasks`: insert, update and delete. Writes follow the same rules as the app's own edits. Status, progress and completion time stay consistent, a subtree moves and deletes as one, and a write that changes nothing doesn't mark the task for sync.
- `instances`: completion columns complete, cancel or reopen that one occurrence. Other content columns edit the whole series. Changing a time column is rejected, so a series is moved through `tasks`.
- `properties`: alarms and categories can be inserted, updated and deleted.
- `tasklists` stays read-only. Batches run in one transaction.
- A client can write back a row it read. Columns it carries unchanged are ignored, and so are rdate/exdate values in the shape they were synced in.
- A task inserted with a time but no `tz` is floating, as in OpenTasks.
- Deleting a task now takes its whole subtree in the app as well, including "this and following" from the first occurrence and in External mode.

Deviation: tag property ids stay stable, with one rare exception. If two tags on one task have hash-colliding spellings, removing one can shift the other's id. Avoiding that would mean storing the ids, which didn't seem worth it at about a 1 in 2³¹ chance.

Closes #63

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/64
2026-10-15 16:16:51 +02:00
Jean-Luc Makiolaandmakiolaj e7bf23e276 Publish the own store through a read-only OpenTasks provider (#43) (#62)
Beta — Codeberg pre-release / detect (push) Successful in 7s
Beta — Codeberg pre-release / beta (push) Skipped
In Own storage mode, Agendula now publishes its Room store as a ContentProvider that follows the OpenTasks contract. It serves `tasklists`, `tasks`, `instances` and `properties` (reminders and tags) at `<package>.tasks`, behind a dangerous-level `<package>.permission.READ_TASKS` that the user grants per app. An app that already reads OpenTasks or tasks.org only has to change the authority and the permission.

On a query, the provider lays the Room store out in OpenTasks' columns and loads that into an in-memory SQLite database, so a client's selection, arguments and sort order run as real SQL. The snapshot is rebuilt after a write, after a storage mode switch, or once it is an hour old, because instance ids and `distance_from_current` move with the clock. Observers on the authority get one notification per burst of changes. In External mode every table is empty.

Recurrence expansion moved out of `RoomTasksDataSource` into shared functions, so the app and the provider expand a series the same way.

`docs/CONTENT_PROVIDER.md` describes the contract for client developers, with the authority and permission for each build. README, CHANGELOG and the privacy policy mention the new permission.

Where this differs from the issue: the issue asked for read and write permissions, and this PR is read-only. Insert, update and delete throw. Accepting writes from other apps means deciding what happens to the fields Agendula keeps verbatim for CalDAV round-tripping, which was the issue's second open question, and that needs its own design. That is tracked in #63.

Closes #43

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/62
2026-10-15 13:25:48 +02:00
Jean-Luc Makiola 35110c96ce docs: Google Play is live (#61)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 11s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Renovate / renovate (push) Successful in 2m6s
Beta — Codeberg pre-release / detect (push) Successful in 4s
Beta — Codeberg pre-release / beta (push) Skipped
2026-10-15 13:00:26 +02:00
Jean-Luc Makiola dcc55a158e docs: F-Droid install, Play note, Weblate widget (#60)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Renovate / renovate (push) Successful in 1m47s
2026-10-14 17:41:10 +02:00
Jean-Luc Makiolaandmakiolaj dd08c035df Release 1.1.0 (#51)
Renovate / renovate (push) Successful in 54s
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 21m44s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 1m32s
Agendula 1.1.0. Merging this triggers the release pipeline (F-Droid, Codeberg, Play) and mints the v1.1.0 tag.

- Tags, synced as CATEGORIES (#35)
- Saved filters, also usable in the Tasks widget (#47)
- Offline flavor without sync or network, published to Codeberg only (#39)
- New Today, Up next, Lists and Week widgets; widgets can show several lists
- Synced lists can be renamed and deleted
- All-day tasks no longer land a day off west of UTC in smart lists and filters

versionName 1.1.0 / versionCode 1010099. CHANGELOG has the 1.1.0 section, and What's New is written for en-US, en-GB, de-DE, pt-BR, plus new fr-FR and ar.

After the tag lands, fdroiddata!49998 needs a 1.1.0 build entry with gradle: full.

Closes #35, closes #39, closes #47

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/51
2026-10-10 15:06:07 +02:00
Jean-Luc Makiola b6f0391cb3 Translations update from Weblate (#34)
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/34
2026-10-10 14:28:03 +02:00
Jean-Luc Makiolaandmakiolaj 22f3d8c2c5 ci: route jobs to the gradle and light runners (#48)
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Renovate / renovate (push) Successful in 1m47s
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 9s
Moves Gradle jobs to the dedicated `gradle` runner (capacity 1, 8g per job) and the cheap gate/lint jobs to `light`. Two runners used to share the `docker` label, so two Gradle builds could run at once and the VPS ran out of memory. Only `runs-on` lines change.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/48
2026-10-10 11:59:22 +02:00
Weblate 4c2a6dc87b Translated using Weblate (Arabic)
Currently translated at 19.4% (112 of 575 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/ar/
2026-10-08 13:09:37 +00:00
Sup99999 33639c0a7c Translated using Weblate (Arabic)
Currently translated at 19.4% (112 of 575 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/ar/
2026-10-08 13:09:37 +00:00
Robin Schanbacher a15e696059 Translated using Weblate (German)
Currently translated at 66.0% (380 of 575 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/de/
2026-10-07 11:37:09 +00:00
Robin Schanbacher b8e797f419 Translated using Weblate (German)
Currently translated at 64.1% (369 of 575 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/de/
2026-10-07 11:37:09 +00:00
Jean-Luc Makiolaandmakiolaj 72b2932197 Agendula: repin floret-kit to shared main, Gradle 9.8 toolchain (#44)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Renovate / renovate (push) Successful in 52s
Moves the floret-kit submodule from v0.4.0 (db4be68) to floret-kit main (8b33635). The kit's per-app branches were folded back into main in jlmakiola/floret-kit#11, so agendula now tracks the same line as calendula, clockula and vouch. The kit adds core-prefs, core-di, CollapsingScaffold FAB/no-back slots, the glance module, LocaleManager on API 33+ and a dependency bump; nothing existing is removed.

floret-kit main requires Gradle >= 9.6 in the consuming build, so this also bumps Gradle 9.5.1 → 9.8.0, AGP 9.2.1 → 9.4.1 and Kotlin 2.3.21 → 2.4.20. Hilt 2.60.1 / KSP 2.3.11 were already new enough. No source changes.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/44
2026-10-07 10:16:36 +02:00
Jean-Luc Makiolaandmakiolaj c4a69d30f7 ci(release): exact tag checks and a retryable beta publish (#40)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 8s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Renovate / renovate (push) Successful in 1m26s
### What this changes

- New `scripts/release_gate.sh`, used by the `detect` jobs of both `release.yaml` and `beta.yaml`, decides whether the committed version still needs publishing. Tags are read by exact name via `git ls-remote` instead of Codeberg's `git/refs/tags/<name>` API.
- A beta counts as done only once its Codeberg pre-release carries the APK, so a publish that failed part-way is redone by the next push of the branch.
- A beta is refused unless it is newer than the latest stable tag (previously only an exact `vX.Y.Z` tag was checked).
- `publish_codeberg_release.sh` fails a pre-release when `CODEBERG_RELEASE_TOKEN` is missing (stable still skips), fails on any asset upload that doesn't return 201, and no longer aborts when the asset listing returns an error.
- `version_info.sh` rejects leading zeros (`1.1.0-beta.01` would have shared `beta.1`'s versionCode under a different tag).
- `beta.yaml` header and `docs/RELEASING.md` updated to match.

### Why

Codeberg's `git/refs/tags/<name>` matches by prefix: asking for `v1.1.0` returns 200 because `v1.1.0-beta.1` exists. Merging 1.1.0 into `main` would have logged "tag already exists" and shipped nothing while the run stayed green, and `1.1.0-beta.2` would have been refused as "already shipped stable".

`release/v1.1.0` needs `main` merged in after this lands, since `beta.yaml` runs from the pushed branch's files.

### Also in here

`gradle/gradle-daemon-jvm.properties` now points at JetBrains' own download URLs for JBR 21.0.11 instead of foojay. foojay dropped JetBrains 21 from its index, so the pinned package ids return 400 and CI couldn't provision the daemon JVM (unrelated to the release change, but it blocks every PR until fixed).

### Checklist

- [x] No `values-*/strings.xml` touched
- [x] No `versionName` / `versionCode` bump
- [x] `CHANGELOG.md` not updated: release infrastructure, not a user-visible change

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/40
2026-10-06 18:32:42 +02:00
114 changed files with 7275 additions and 285 deletions
+2 -2
View File
@@ -27,7 +27,7 @@ jobs:
detect:
# Gitea only; see the same guard in release.yaml.
if: github.repository_owner == 'makiolaj'
runs-on: docker
runs-on: light
outputs:
is_beta: ${{ steps.v.outputs.is_beta }}
version: ${{ steps.v.outputs.version }}
@@ -57,7 +57,7 @@ jobs:
beta:
needs: detect
if: needs.detect.outputs.is_beta == 'true'
runs-on: docker
runs-on: gradle
env:
ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk
+2 -2
View File
@@ -48,7 +48,7 @@ jobs:
# secrets. repository_owner differs between the two forges regardless of
# URL, proxy or instance rename, so this closes it permanently.
if: github.repository_owner == 'makiolaj'
runs-on: docker
runs-on: light
outputs:
is_release: ${{ steps.v.outputs.is_release }}
version: ${{ steps.v.outputs.version }}
@@ -105,7 +105,7 @@ jobs:
release:
needs: detect
if: needs.detect.outputs.is_release == 'true' || github.event_name == 'workflow_dispatch'
runs-on: docker
runs-on: gradle
env:
ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk
+26
View File
@@ -7,6 +7,17 @@ All notable changes to this project are documented here. The format follows
## [Unreleased]
### Added
- Other apps can read the tasks kept in Agendula's own database through an
OpenTasks-compatible provider, once you grant them permission. Apps that
already support OpenTasks or tasks.org need only a small change to support
Agendula too (#43).
- With a second permission, other apps can also add, edit, complete and delete
those tasks, including their reminders and tags. They can tick off a single
occurrence of a recurring task. Task lists stay read-only for them (#63).
## [1.1.0] - 2026-10-10
### Added
- Four new home-screen widgets: Today, Up next, Lists and Week.
- A widget can show tasks from several lists at once, optionally grouped by
@@ -14,6 +25,13 @@ All notable changes to this project are documented here. The format follows
- A setting under Settings → Task form pre-fills a new task's start with today.
- An offline version of Agendula with no sync and no network access at all,
published as a separate APK on Codeberg (#39).
- Tags. Add them while editing a task, see them on the task and in lists,
browse them from the home screen and filter any list by them. They are saved
as `CATEGORIES`, so they sync with Nextcloud Tasks, tasks.org and DAVx⁵ (#35).
- Filters. Save your own view of the open tasks by due date (overdue, today,
upcoming, no date, in any combination), lists, tags and minimum priority.
They live on the home screen and can be shown in the Tasks widget, so today
and overdue tasks can now share one widget (#47).
### Changed
- The Tasks widget has agenda-style rows, an optional row colour and an "All
@@ -23,9 +41,17 @@ All notable changes to this project are documented here. The format follows
showed.
- Synced lists can be renamed and deleted, not only device-only ones. A
read-only share shows the edit button greyed out and explains why on tap.
- The sort button in task lists now opens "Sort and filter", which can also
filter by tag.
- While editing a task, its title moves into the top bar once you scroll past
it.
### Fixed
- The list editor's Where and smart-lists rows no longer have double padding.
- A field you tap now scrolls fully above the keyboard, and the "add subtask"
row in task lists no longer ends up hidden behind it.
- All-day tasks west of UTC no longer show up a day early in Overdue, Today
and Upcoming.
## [1.0.0] - 2026-09-21
+43 -5
View File
@@ -15,6 +15,10 @@ required.</p>
</p>
<p>
<a href="https://f-droid.org/packages/de.jeanlucmakiola.agendula/"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="56"></a>
&nbsp;
<a href="https://play.google.com/store/apps/details?id=de.jeanlucmakiola.agendula"><img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" alt="Get it on Google Play" height="56"></a>
&nbsp;
<a href="https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/agendula"><img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png?raw=true" alt="Get it on Obtainium" height="56"></a>
&nbsp;
<a href="https://ko-fi.com/jeanlucmakiola"><img src="https://storage.ko-fi.com/cdn/brandasset/v2/support_me_on_kofi_badge_beige.png" alt="Support me on Ko-fi" height="56"></a>
@@ -76,6 +80,16 @@ back to a full comparison where it does not. Everything the store does not model
is kept verbatim and sent back unchanged, so passing your tasks through Agendula
does not quietly lose fields another client wrote.
### Tested With
The following CalDAV Services have been Tested
| Service | Version | Date | Status | User |
|---|---|---|---|---|
| **Nextcloud** | 25.0.0 | Oct 9th 2026 | ✅ | [@jlmakiola](https://codeberg.org/jlmakiola) [@Schanbro](https://codeberg.org/Schanbro)
## Where your tasks live
| | Where | Sync | Needs |
@@ -87,6 +101,10 @@ Agendula's own store is an ordinary app database, so it **coexists with
OpenTasks rather than replacing it**. If you already sync through a provider,
Agendula can work on top of it exactly as before.
Other apps can read and write the tasks in Agendula's own store through an
OpenTasks-compatible provider, once you grant them permission. See
[docs/CONTENT_PROVIDER.md](docs/CONTENT_PROVIDER.md).
Switching between the two moves nothing — each store keeps its own tasks — so
**Settings → Storage** asks before it switches, and offers to **copy** a
provider's tasks into Agendula's own store when you want to move over. The copy
@@ -97,10 +115,20 @@ proprietary, and open standards — CalDAV and iCalendar — are the lane.
## Install
### F-Droid repository
### F-Droid (recommended)
Every release is built, signed and published to a self-hosted F-Droid
repository. Add it once and your F-Droid client handles updates from then on:
Agendula is in the official [F-Droid](https://f-droid.org) repository. Search
for Agendula in any F-Droid client, or
[install it from f-droid.org](https://f-droid.org/packages/de.jeanlucmakiola.agendula/).
F-Droid builds from source on its own schedule, so a new version usually shows
up there a few days after release.
### Self-hosted F-Droid repo (fastest updates)
The release pipeline builds, signs and publishes every release to a
self-hosted F-Droid repository, so new versions land there first. Add it once
and your F-Droid client handles updates from then on:
1. In your F-Droid client, open *Settings → Repositories → Add* (or open the
link below on your phone):
@@ -125,6 +153,15 @@ app.
Betas of upcoming versions are published there too, as pre-releases; to test
them, switch on *Include prereleases* for Agendula in Obtainium.
### Google Play
Agendula is on
[Google Play](https://play.google.com/store/apps/details?id=de.jeanlucmakiola.agendula).
Play builds are signed with Google's key rather than mine, so switching between
Play and any other channel means uninstalling first. Tasks kept only in
Agendula's own database go with it, so sync them to a CalDAV account first.
### Offline version
Each Codeberg release also carries `agendula-offline_v<version>.apk`: Agendula
@@ -148,8 +185,9 @@ JDK 17 and the Android SDK are all it needs; see
## Translations
Translations are managed on a self-hosted **Weblate**, and partial ones are
fine — an untranslated string simply falls back to English. Agendula ships in
English, German and Brazilian Portuguese so far.
fine — an untranslated string simply falls back to English.
<a href="https://weblate.dev.jeanlucmakiola.de/engage/agendula/"><img src="https://weblate.dev.jeanlucmakiola.de/widget/agendula/multi-auto.svg" alt="Translation status" width="600"></a>
**→ [Help translate Agendula](https://weblate.dev.jeanlucmakiola.de/engage/agendula/)**
+2 -2
View File
@@ -33,8 +33,8 @@ android {
// (1.0.x: 1.0.0 -> 10000; from 1.1.0: 1.1.0-beta.1 -> 1010001,
// 1.1.0 -> 1010099), and CI fails if the committed one doesn't match.
// See docs/RELEASING.md.
versionCode = 1010002
versionName = "1.1.0-beta.2"
versionCode = 1010099
versionName = "1.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -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")
}
@Test
fun importsCategoryPropertiesAsTags() {
importer.importFrom(fixture())
val tasks = taskRows()
assertThat(tasks.getValue("Buy milk").categories).containsExactly("Errands")
assertThat(tasks.getValue("Ship the release").categories).isEmpty()
}
// --- running it -----------------------------------------------------------
@Test
@@ -0,0 +1,157 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
import android.content.ContentProviderOperation
import android.content.ContentValues
import android.content.Context
import android.content.pm.PermissionInfo
import android.os.Build
import android.os.ParcelFileDescriptor
import androidx.core.net.toUri
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import org.junit.Assert.assertThrows
import org.junit.Assume.assumeTrue
import org.junit.Test
import org.junit.runner.RunWith
/** The provider as another app meets it: manifest, permission, URIs. */
@RunWith(AndroidJUnit4::class)
class AgendulaTaskProviderTest {
private val context = ApplicationProvider.getApplicationContext<Context>()
private val authority = "${context.packageName}.tasks"
private val readPermission = "${context.packageName}.permission.READ_TASKS"
private val writePermission = "${context.packageName}.permission.WRITE_TASKS"
private val resolver = context.contentResolver
private fun uri(path: String) = "content://$authority/$path".toUri()
@Test
fun isExportedUnderItsPermissions() {
val info = requireNotNull(context.packageManager.resolveContentProvider(authority, 0))
assertThat(info.name).isEqualTo(AgendulaTaskProvider::class.java.name)
assertThat(info.exported).isTrue()
assertThat(info.readPermission).isEqualTo(readPermission)
assertThat(info.writePermission).isEqualTo(writePermission)
}
@Test
fun bothPermissionsAreDangerous() {
listOf(readPermission, writePermission).forEach { permission ->
val info = context.packageManager.getPermissionInfo(permission, 0)
assertThat(info.protection).isEqualTo(PermissionInfo.PROTECTION_DANGEROUS)
assertThat(info.loadLabel(context.packageManager).toString()).isNotEmpty()
assertThat(info.loadDescription(context.packageManager)?.toString()).isNotEmpty()
}
}
@Test
fun anAppWithoutThePermissionIsDenied() {
assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
// The shell is another uid that was never granted it.
val output = shell("content query --uri content://$authority/tasks")
assertThat(output).contains("Permission Denial")
}
@Test
fun anAppWithoutTheWritePermissionCannotWrite() {
assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
val output = shell("content insert --uri content://$authority/tasks --bind title:s:x --bind list_id:i:1")
assertThat(output).contains("Permission Denial")
assertThat(output).contains(writePermission)
}
@Test
fun reportsOpenTasksMimeTypes() {
ProviderTable.entries.forEach { table ->
assertThat(resolver.getType(uri(table.path))).isEqualTo(table.mimeType(single = false))
assertThat(resolver.getType(uri("${table.path}/3"))).isEqualTo(table.mimeType(single = true))
}
}
@Test
fun everyTableAnswersWithItsColumns() {
ProviderTable.entries.forEach { table ->
resolver.query(uri(table.path), null, null, null, null)!!.use {
assertThat(it.columnNames.toList()).containsExactlyElementsIn(table.columns.map { c -> c.first }).inOrder()
}
}
}
@Test
fun aCursorWatchesTheUriItWasQueriedFor() {
val tasks = uri("tasks")
resolver.query(tasks, null, null, null, null)!!.use { assertThat(it.notificationUri).isEqualTo(tasks) }
}
@Test
fun aRowUriReadsOnlyThatRow() {
// Ids start at 1, so 0 names nothing whatever the store holds.
resolver.query(uri("tasks/0"), null, null, null, null)!!.use { assertThat(it.count).isEqualTo(0) }
resolver.query(uri("tasks/0"), null, "1 = 1 OR 1 = 1", null, null)!!.use { assertThat(it.count).isEqualTo(0) }
}
@Test
fun anUnknownUriIsRejected() {
assertThrows(IllegalArgumentException::class.java) { resolver.query(uri("events"), null, null, null, null) }
// ContentResolver.getType swallows the provider's exception and answers null.
assertThat(runCatching { resolver.getType(uri("tasks/abc")) }.getOrNull()).isNull()
}
@Test
fun listsAreReadOnly() {
val values = ContentValues().apply { put("list_name", "x") }
assertThrows(UnsupportedOperationException::class.java) { resolver.insert(uri("tasklists"), values) }
assertThrows(UnsupportedOperationException::class.java) { resolver.update(uri("tasklists/1"), values, null, null) }
assertThrows(UnsupportedOperationException::class.java) { resolver.delete(uri("tasklists/1"), null, null) }
}
@Test
fun aRowUriTakesNoInsert() {
assertThrows(IllegalArgumentException::class.java) {
resolver.insert(uri("tasks/1"), ContentValues().apply { put("title", "x") })
}
}
@Test
fun aBadWriteReachesTheClientAsIllegalArgument() {
// No list has this id, so nothing in the app's real store is touched.
val values = ContentValues().apply {
put("list_id", Long.MAX_VALUE)
put("title", "x")
}
assertThrows(IllegalArgumentException::class.java) { resolver.insert(uri("tasks"), values) }
}
@Test
fun aFailingBatchWritesNothing() {
val missingList = Long.MAX_VALUE
val operations = arrayListOf(
ContentProviderOperation.newInsert(uri("tasks")).withValue("list_id", missingList).withValue("title", "a").build(),
)
assertThrows(IllegalArgumentException::class.java) { resolver.applyBatch(authority, operations) }
resolver.query(uri("tasks"), arrayOf("_id"), "list_id = ?", arrayOf(missingList.toString()), null)!!
.use { assertThat(it.count).isEqualTo(0) }
}
/** stdout and stderr of [command]; the denial lands on stderr. */
private fun shell(command: String): String {
val (stdout, stdin, stderr) = InstrumentationRegistry.getInstrumentation().uiAutomation
.executeShellCommandRwe(command)
stdin.close()
return listOf(stdout, stderr).joinToString("\n") { pfd ->
ParcelFileDescriptor.AutoCloseInputStream(pfd).bufferedReader().use { it.readText() }
}
}
}
@@ -0,0 +1,214 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
import android.content.Context
import android.database.ContentObserver
import android.database.Cursor
import android.net.Uri
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.tasks.ProviderEnvironment
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import de.jeanlucmakiola.agendula.data.tasks.TasksContract
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
/** The snapshot follows the Room store, the storage mode and the clock, and says when it moved. */
@RunWith(AndroidJUnit4::class)
class ProviderSnapshotSourceTest {
private val context = ApplicationProvider.getApplicationContext<Context>()
private lateinit var db: TasksDatabase
private lateinit var resolver: ProviderResolver
private lateinit var source: ProviderSnapshotSource
private val clock = MutableClock(Instant.parse("2026-10-10T08:00:00Z"))
private val changes = LinkedBlockingQueue<Uri>()
private val observer = object : ContentObserver(null) {
override fun onChange(selfChange: Boolean, uri: Uri?) {
changes += uri ?: Uri.EMPTY
}
}
private var listId = 0L
@Before
fun setUp() {
db = Room.inMemoryDatabaseBuilder(context, TasksDatabase::class.java).allowMainThreadQueries().build()
resolver = ProviderResolver(NoProviderInstalled)
source = ProviderSnapshotSource(context, db, resolver, awaitStartup = {}, clock = clock)
listId = db.taskLists().insert(TaskListEntity(name = "Home", color = 0xFF112233.toInt()))
context.contentResolver.registerContentObserver(TasksContract.authorityUri(source.authority), true, observer)
}
@After
fun tearDown() {
context.contentResolver.unregisterContentObserver(observer)
db.close()
}
private fun query(table: ProviderTable, selection: String? = null, args: Array<String>? = null): Cursor =
source.query(table, null, selection, args, null)
private fun titles(): List<String?> = query(ProviderTable.TASKS).use { cursor ->
val index = cursor.getColumnIndexOrThrow(Tasks.TITLE)
buildList { while (cursor.moveToNext()) add(cursor.getString(index)) }
}
private fun insert(title: String, due: Instant? = null, rrule: String? = null): Long =
db.tasks().insert(TaskEntity(listId = listId, uid = title, title = title, due = due, rrule = rrule, timezone = "UTC"))
/** Waits for the coalesced notification, then drains any stragglers. */
private fun awaitChange() {
assertThat(changes.poll(5, TimeUnit.SECONDS)).isNotNull()
Thread.sleep(700)
changes.clear()
}
private fun assertNoChange() {
assertThat(changes.poll(1500, TimeUnit.MILLISECONDS)).isNull()
}
@Test
fun servesTheOwnStore() {
val id = insert("Milk")
db.alarms().insert(TaskAlarmEntity(taskId = id, minutesBefore = 10))
assertThat(titles()).containsExactly("Milk")
query(ProviderTable.LISTS).use {
it.moveToFirst()
assertThat(it.getString(it.getColumnIndexOrThrow(Lists.NAME))).isEqualTo("Home")
}
query(ProviderTable.INSTANCES).use { assertThat(it.count).isEqualTo(1) }
query(ProviderTable.PROPERTIES).use { assertThat(it.count).isEqualTo(1) }
}
@Test
fun aDeletedTaskIsNotPublished() {
val id = insert("Gone")
insert("Kept")
db.tasks().markDeleted(id, clock.now())
assertThat(titles()).containsExactly("Kept")
}
@Test
fun aWriteNotifiesObserversAndIsServedNext() {
source.start()
assertThat(titles()).isEmpty()
insert("Later")
awaitChange()
assertThat(titles()).containsExactly("Later")
}
@Test
fun aBurstOfWritesNotifiesOnce() {
source.start()
titles()
repeat(5) { insert("t$it") }
assertThat(changes.poll(5, TimeUnit.SECONDS)).isNotNull()
assertNoChange()
}
@Test
fun externalModeServesNothing() {
insert("Mine")
resolver.storageMode = StorageMode.EXTERNAL
ProviderTable.entries.forEach { table -> query(table).use { assertThat(it.count).isEqualTo(0) } }
}
@Test
fun switchingModeNotifiesAndRebuilds() {
source.start()
insert("Mine")
assertThat(titles()).containsExactly("Mine")
changes.clear()
resolver.storageMode = StorageMode.EXTERNAL
awaitChange()
assertThat(titles()).isEmpty()
resolver.storageMode = StorageMode.OWN
awaitChange()
assertThat(titles()).containsExactly("Mine")
}
@Test
fun writesInExternalModeDoNotNotify() {
resolver.storageMode = StorageMode.EXTERNAL
source.start()
titles()
insert("Hidden")
assertNoChange()
}
@Test
fun aClientSelectionNarrowsTheRows() {
insert("a")
insert("b")
query(ProviderTable.TASKS, "${Tasks.TITLE} = ?", arrayOf("b")).use { assertThat(it.count).isEqualTo(1) }
}
@Test
fun anHourOldSnapshotIsRebuiltAgainstTheClock() {
source.start()
insert("Daily", due = Instant.parse("2026-10-10T09:00:00Z"), rrule = "FREQ=DAILY;COUNT=3")
awaitChange()
assertThat(currentOriginalTime()).isEqualTo(Instant.parse("2026-10-10T09:00:00Z").toEpochMilliseconds())
clock.current += 1.days
assertThat(currentOriginalTime()).isEqualTo(Instant.parse("2026-10-11T09:00:00Z").toEpochMilliseconds())
awaitChange()
}
@Test
fun aFreshSnapshotIsReused() {
insert("a")
titles()
clock.current += 30.minutes
query(ProviderTable.INSTANCES).use { assertThat(it.count).isEqualTo(1) }
assertNoChange()
}
private fun currentOriginalTime(): Long =
query(ProviderTable.INSTANCES, "${Instances.DISTANCE_FROM_CURRENT} = 0").use {
assertThat(it.moveToFirst()).isTrue()
it.getLong(it.getColumnIndexOrThrow(Instances.INSTANCE_ORIGINAL_TIME))
}
}
private class MutableClock(var current: Instant) : Clock {
override fun now(): Instant = current
}
private object NoProviderInstalled : ProviderEnvironment {
override fun packageDeclaring(authority: String): String? = null
override fun isGranted(permission: String): Boolean = false
override fun appLabel(packageName: String): String? = null
}
@@ -0,0 +1,811 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.tasks.ProviderEnvironment
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import de.jeanlucmakiola.agendula.data.tasks.TasksContract
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Alarm
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Category
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import de.jeanlucmakiola.agendula.domain.TaskStatus
import org.junit.After
import org.junit.Assert.assertThrows
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.awaitCancellation
import kotlin.time.Instant
/** Writes from another app, applied to the own store through the published tables. */
@RunWith(AndroidJUnit4::class)
class ProviderWriterTest {
private val context = ApplicationProvider.getApplicationContext<Context>()
private lateinit var db: TasksDatabase
private lateinit var resolver: ProviderResolver
private lateinit var source: ProviderSnapshotSource
private lateinit var writer: ProviderWriter
private var afterWrites = 0
private var home = 0L
/** Truncated to the store's granularity, and on the hour so occurrences land on round times. */
private val today = Instant.fromEpochMilliseconds(Clock.System.now().toEpochMilliseconds() / 3_600_000 * 3_600_000)
@Before
fun setUp() {
db = Room.inMemoryDatabaseBuilder(context, TasksDatabase::class.java).allowMainThreadQueries().build()
resolver = ProviderResolver(NoProvider)
source = ProviderSnapshotSource(context, db, resolver, awaitStartup = {})
writer = ProviderWriter(db, RoomTasksDataSource(db), source, resolver, afterWrite = { afterWrites++ })
home = db.taskLists().insert(TaskListEntity(name = "Home", color = 0))
}
@After
fun tearDown() = db.close()
private fun task(id: Long) = db.tasks().entity(id)
private fun insert(vararg values: Pair<String, Any?>): Long =
writer.insert(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to home, *values))
private fun byId(table: ProviderTable, id: Long) = "${table.idColumn} = $id"
private fun synced(readOnly: Boolean = false): Long {
val account = db.accounts().insert(AccountEntity(displayName = "me", username = "me"))
return db.taskLists().insert(
TaskListEntity(name = "Work", color = 0, accountId = account, href = "https://s/w/", isReadOnly = readOnly),
)
}
private fun dailySeries(title: String = "Daily"): Long = db.tasks().insert(
TaskEntity(listId = home, uid = title, title = title, dtstart = today - 1.days, due = today - 1.days + 1.hours,
rrule = "FREQ=DAILY;COUNT=5", timezone = "UTC"),
)
/** Each instance of [taskId]'s series by its original time, with its task row and status. */
private fun instances(): List<Triple<Long, Long, Int>> =
source.rows(
ProviderTable.INSTANCES,
listOf(Instances.INSTANCE_ORIGINAL_TIME, Instances.TASK_ID, Tasks.STATUS),
null,
null,
).map { (original, taskId, status) -> Triple(original as Long, taskId as Long, (status as Long).toInt()) }
private fun instanceId(original: Instant): Long =
source.rows(
ProviderTable.INSTANCES,
listOf(Tasks.ID),
"${Instances.INSTANCE_ORIGINAL_TIME} = ?",
arrayOf(original.toEpochMilliseconds().toString()),
).single().single() as Long
// --- inserts ----------------------------------------------------------------
@Test
fun anInsertedTaskIsPublishedAndOwedASync() {
val id = insert(Tasks.TITLE to "Milk", Tasks.PRIORITY to 1)
val stored = task(id)!!
assertThat(stored.title).isEqualTo("Milk")
assertThat(stored.priority).isEqualTo(1)
assertThat(stored.isDirty).isTrue()
assertThat(source.rows(ProviderTable.TASKS, listOf(Tasks.TITLE), byId(ProviderTable.TASKS, id), null))
.containsExactly(listOf("Milk"))
assertThat(afterWrites).isEqualTo(1)
}
@Test
fun anInsertNeedsAListThatExistsAndIsWritable() {
assertThrows(IllegalArgumentException::class.java) { writer.insert(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x")) }
assertThrows(IllegalArgumentException::class.java) { insert(Tasks.LIST_ID to 999L) }
val readOnly = synced(readOnly = true)
assertThrows(UnsupportedOperationException::class.java) { insert(Tasks.LIST_ID to readOnly) }
assertThat(db.tasks().live()).isEmpty()
}
@Test
fun anOccurrenceCannotBeInsertedAsATask() {
val series = dailySeries()
assertThrows(IllegalArgumentException::class.java) {
insert(Tasks.ORIGINAL_INSTANCE_ID to series, Tasks.ORIGINAL_INSTANCE_TIME to today.toEpochMilliseconds())
}
}
@Test
fun aDuplicateUidIsRejected() {
insert(Tasks.UID to "same")
assertThrows(IllegalArgumentException::class.java) { insert(Tasks.UID to "same") }
}
@Test
fun listsAndInstancesTakeNoInserts() {
assertThrows(UnsupportedOperationException::class.java) {
writer.insert(ProviderTable.LISTS, mapOf(TasksContract.Lists.NAME to "New"))
}
assertThrows(UnsupportedOperationException::class.java) { writer.insert(ProviderTable.INSTANCES, emptyMap()) }
assertThrows(UnsupportedOperationException::class.java) {
writer.update(ProviderTable.LISTS, mapOf(TasksContract.Lists.NAME to "x"), null, null)
}
assertThrows(UnsupportedOperationException::class.java) { writer.delete(ProviderTable.LISTS, null, null) }
}
@Test
fun anUnknownColumnIsRejected() {
assertThrows(IllegalArgumentException::class.java) { insert("etag" to "x") }
}
@Test
fun nothingIsWrittenInExternalMode() {
val id = insert(Tasks.TITLE to "Mine")
resolver.storageMode = StorageMode.EXTERNAL
assertThrows(IllegalStateException::class.java) { insert() }
assertThrows(IllegalStateException::class.java) {
writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), null, null)
}
assertThat(task(id)!!.title).isEqualTo("Mine")
}
// --- updates ----------------------------------------------------------------
@Test
fun anUpdateChangesTheRowsItsSelectionMatches() {
val a = insert(Tasks.TITLE to "a")
val b = insert(Tasks.TITLE to "b")
val count = writer.update(ProviderTable.TASKS, mapOf(Tasks.STATUS to 2), "${Tasks.TITLE} = ?", arrayOf("b"))
assertThat(count).isEqualTo(1)
assertThat(task(a)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
assertThat(task(b)!!.status).isEqualTo(TaskStatus.COMPLETED)
assertThat(task(b)!!.completedAt).isNotNull()
}
@Test
fun anUpdateMatchingNothingChangesNothing() {
insert(Tasks.TITLE to "a")
assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), "${Tasks.TITLE} = 'zzz'", null)).isEqualTo(0)
}
@Test
fun aTaskInAReadOnlyListCannotChange() {
val readOnly = synced(readOnly = true)
val id = db.tasks().insert(TaskEntity(listId = readOnly, uid = "r", title = "theirs"))
assertThrows(UnsupportedOperationException::class.java) {
writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "mine"), byId(ProviderTable.TASKS, id), null)
}
assertThrows(UnsupportedOperationException::class.java) {
writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, id), null)
}
assertThat(task(id)!!.title).isEqualTo("theirs")
}
@Test
fun aTaskMovesBetweenLists() {
val work = synced()
val id = insert(Tasks.TITLE to "t")
writer.update(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to work), byId(ProviderTable.TASKS, id), null)
assertThat(db.tasks().live().single { it.title == "t" }.listId).isEqualTo(work)
}
@Test
fun aParentMustBeInTheSameListAndNotADescendant() {
val work = synced()
val parent = insert(Tasks.TITLE to "parent")
val child = insert(Tasks.TITLE to "child", Tasks.PARENT_ID to parent)
val elsewhere = insert(Tasks.LIST_ID to work, Tasks.TITLE to "elsewhere")
assertThat(task(child)!!.parentId).isEqualTo(parent)
assertThrows(IllegalArgumentException::class.java) {
writer.update(ProviderTable.TASKS, mapOf(Tasks.PARENT_ID to elsewhere), byId(ProviderTable.TASKS, child), null)
}
assertThrows(IllegalArgumentException::class.java) {
writer.update(ProviderTable.TASKS, mapOf(Tasks.PARENT_ID to child), byId(ProviderTable.TASKS, parent), null)
}
assertThrows(IllegalArgumentException::class.java) { insert(Tasks.PARENT_ID to 999L) }
}
@Test
fun movingASeriesDropsTheOverridesItNoLongerGenerates() {
val series = dailySeries()
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(db.tasks().overrides(series)).hasSize(1)
writer.update(
ProviderTable.TASKS,
mapOf(Tasks.DTSTART to (today + 2.hours).toEpochMilliseconds(), Tasks.DUE to (today + 3.hours).toEpochMilliseconds()),
byId(ProviderTable.TASKS, series),
null,
)
assertThat(db.tasks().overrides(series)).isEmpty()
}
@Test
fun aClientCannotTouchSyncState() {
val work = synced()
val id = db.tasks().insert(
TaskEntity(listId = work, uid = "s", title = "t", href = "/s.ics", etag = "e", sequence = 2, unknownProperties = "X-A:1"),
)
writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "new", "_sync_id" to "x", "sync1" to "y"), byId(ProviderTable.TASKS, id), null)
val stored = task(id)!!
assertThat(stored.title).isEqualTo("new")
assertThat(stored.href).isEqualTo("/s.ics")
assertThat(stored.etag).isEqualTo("e")
assertThat(stored.sequence).isEqualTo(2)
assertThat(stored.unknownProperties).isEqualTo("X-A:1")
assertThat(stored.isDirty).isTrue()
}
// --- instances --------------------------------------------------------------
@Test
fun completingAnOccurrenceTicksOffOnlyThatOne() {
val series = dailySeries()
val count = writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(count).isEqualTo(1)
assertThat(task(series)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
val override = db.tasks().overrides(series).single()
assertThat(override.recurrenceId).isEqualTo(today)
assertThat(override.status).isEqualTo(TaskStatus.COMPLETED)
val statuses = instances().associate { it.first to it.third }
assertThat(statuses[today.toEpochMilliseconds()]).isEqualTo(TasksContract.STATUS_COMPLETED)
assertThat(statuses.filterKeys { it != today.toEpochMilliseconds() }.values.toSet())
.containsExactly(TasksContract.STATUS_NEEDS_ACTION)
}
@Test
fun anOccurrenceCanBeCancelledAndReopened() {
val series = dailySeries()
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 3), byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.CANCELLED)
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.PERCENT_COMPLETE to 0), byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.NEEDS_ACTION)
}
@Test
fun contentWrittenToAnOccurrenceEditsTheSeries() {
val series = dailySeries()
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.TITLE to "Renamed"), byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(task(series)!!.title).isEqualTo("Renamed")
assertThat(db.tasks().overrides(series)).isEmpty()
}
@Test
fun anOccurrenceCannotBeMoved() {
dailySeries()
assertThrows(IllegalArgumentException::class.java) {
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.DUE to 0L), byId(ProviderTable.INSTANCES, instanceId(today)), null)
}
}
@Test
fun anOccurrenceHasNoPartialProgress() {
dailySeries()
assertThrows(IllegalArgumentException::class.java) {
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.PERCENT_COMPLETE to 50), byId(ProviderTable.INSTANCES, instanceId(today)), null)
}
}
@Test
fun aPlainTaskInstanceWritesTheTask() {
val id = insert(Tasks.TITLE to "t", Tasks.DUE to today.toEpochMilliseconds())
val instance = source.rows(ProviderTable.INSTANCES, listOf(Tasks.ID), "${Instances.TASK_ID} = $id", null).single().single() as Long
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.PERCENT_COMPLETE to 50, Tasks.DUE to (today + 1.days).toEpochMilliseconds()), byId(ProviderTable.INSTANCES, instance), null)
assertThat(task(id)!!.status).isEqualTo(TaskStatus.IN_PROCESS)
assertThat(task(id)!!.due).isEqualTo(today + 1.days)
}
@Test
fun deletingAnOccurrenceExceptsIt() {
val series = dailySeries()
assertThat(writer.delete(ProviderTable.INSTANCES, byId(ProviderTable.INSTANCES, instanceId(today)), null)).isEqualTo(1)
assertThat(task(series)!!.exdate).isNotNull()
assertThat(instances().map { it.first }).doesNotContain(today.toEpochMilliseconds())
assertThat(instances()).hasSize(4)
}
@Test
fun deletingAModifiedOccurrenceExceptsItToo() {
val series = dailySeries()
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
writer.delete(ProviderTable.INSTANCES, byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(db.tasks().overrides(series)).isEmpty()
assertThat(instances().map { it.first }).doesNotContain(today.toEpochMilliseconds())
}
// --- deletes ----------------------------------------------------------------
@Test
fun aParentGoesWithItsSubtasks() {
val parent = insert(Tasks.TITLE to "parent")
val child = insert(Tasks.TITLE to "child", Tasks.PARENT_ID to parent)
val grandchild = insert(Tasks.TITLE to "grandchild", Tasks.PARENT_ID to child)
val other = insert(Tasks.TITLE to "other")
assertThat(writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, parent), null)).isEqualTo(1)
assertThat(task(parent)).isNull()
assertThat(task(child)).isNull()
assertThat(task(grandchild)).isNull()
assertThat(task(other)).isNotNull()
}
@Test
fun aSyncedTaskLeavesATombstone() {
val work = synced()
val id = insert(Tasks.LIST_ID to work, Tasks.TITLE to "t")
writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, id), null)
assertThat(task(id)!!.isDeleted).isTrue()
assertThat(source.rows(ProviderTable.TASKS, listOf(Tasks.ID), null, null)).isEmpty()
assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), byId(ProviderTable.TASKS, id), null)).isEqualTo(0)
}
@Test
fun deletingAnOverrideThroughTasksExceptsTheOccurrence() {
val series = dailySeries()
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
val override = db.tasks().overrides(series).single().id
writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, override), null)
assertThat(task(series)).isNotNull()
assertThat(task(series)!!.exdate).isNotNull()
assertThat(instances()).hasSize(4)
}
// --- properties -------------------------------------------------------------
@Test
fun anAlarmCanBeAddedChangedAndRemoved() {
val id = insert(Tasks.TITLE to "t", Tasks.DUE to (today + 1.days).toEpochMilliseconds())
db.tasks().update(task(id)!!.copy(isDirty = false))
val propertyId = writer.insert(
ProviderTable.PROPERTIES,
mapOf(Properties.TASK_ID to id, Properties.MIMETYPE to Alarm.MIMETYPE, Alarm.MINUTES_BEFORE to "15", Alarm.REFERENCE to "1"),
)
val alarm = db.alarms().forTask(id).single()
assertThat(alarm.minutesBefore).isEqualTo(15)
assertThat(alarm.reference).isEqualTo(AlarmReference.DUE)
assertThat(task(id)!!.isDirty).isTrue()
assertThat(propertyId).isEqualTo(ProviderIds.alarm(alarm.id))
writer.update(ProviderTable.PROPERTIES, mapOf(Alarm.MINUTES_BEFORE to 60, Alarm.REFERENCE to 2), byId(ProviderTable.PROPERTIES, propertyId), null)
assertThat(db.alarms().forTask(id).single().minutesBefore).isEqualTo(60)
assertThat(db.alarms().forTask(id).single().reference).isEqualTo(AlarmReference.START)
writer.delete(ProviderTable.PROPERTIES, "${Properties.MIMETYPE} = ?", arrayOf(Alarm.MIMETYPE))
assertThat(db.alarms().forTask(id)).isEmpty()
}
@Test
fun tagsCanBeAddedRenamedAndRemoved() {
val id = insert(Tasks.TITLE to "t")
val propertyId = writer.insert(
ProviderTable.PROPERTIES,
mapOf(Properties.TASK_ID to id, Properties.MIMETYPE to Category.MIMETYPE, Category.NAME to " errand "),
)
assertThat(task(id)!!.categories).containsExactly("errand")
writer.update(ProviderTable.PROPERTIES, mapOf(Category.NAME to "shopping"), byId(ProviderTable.PROPERTIES, propertyId), null)
assertThat(task(id)!!.categories).containsExactly("shopping")
writer.insert(ProviderTable.PROPERTIES, mapOf(Properties.TASK_ID to id, Properties.MIMETYPE to Category.MIMETYPE, Category.NAME to "shopping"))
assertThat(task(id)!!.categories).containsExactly("shopping")
writer.delete(ProviderTable.PROPERTIES, "${Category.NAME} = ?", arrayOf("shopping"))
assertThat(task(id)!!.categories).isEmpty()
}
@Test
fun aPropertyCannotChangeItsKindOrTask() {
val id = insert(Tasks.TITLE to "t")
val other = insert(Tasks.TITLE to "o")
db.alarms().insert(TaskAlarmEntity(taskId = id, minutesBefore = 5))
val propertyId = source.rows(ProviderTable.PROPERTIES, listOf(Properties.PROPERTY_ID), null, null).single().single() as Long
assertThrows(IllegalArgumentException::class.java) {
writer.update(ProviderTable.PROPERTIES, mapOf(Properties.MIMETYPE to Category.MIMETYPE), byId(ProviderTable.PROPERTIES, propertyId), null)
}
assertThrows(IllegalArgumentException::class.java) {
writer.update(ProviderTable.PROPERTIES, mapOf(Properties.TASK_ID to other), byId(ProviderTable.PROPERTIES, propertyId), null)
}
}
@Test
fun onlyAlarmsAndTagsCanBeWritten() {
val id = insert(Tasks.TITLE to "t")
assertThrows(IllegalArgumentException::class.java) {
writer.insert(ProviderTable.PROPERTIES, mapOf(Properties.TASK_ID to id, Properties.MIMETYPE to "vnd.android.cursor.item/comment"))
}
assertThrows(IllegalArgumentException::class.java) {
writer.insert(ProviderTable.PROPERTIES, mapOf(Properties.TASK_ID to id, Properties.MIMETYPE to Alarm.MIMETYPE))
}
}
// --- batches ----------------------------------------------------------------
@Test
fun aBatchIsAllOrNothing() {
assertThrows(IllegalArgumentException::class.java) {
writer.batch {
insert(Tasks.TITLE to "first")
insert(Tasks.TITLE to "second")
insert(Tasks.LIST_ID to 999L)
}
}
assertThat(db.tasks().live()).isEmpty()
assertThat(source.rows(ProviderTable.TASKS, listOf(Tasks.ID), null, null)).isEmpty()
}
@Test
fun aBatchSeesItsOwnWritesAndReArmsRemindersOnce() {
writer.batch {
insert(Tasks.TITLE to "first")
writer.update(ProviderTable.TASKS, mapOf(Tasks.STATUS to 2), "${Tasks.TITLE} = ?", arrayOf("first"))
}
assertThat(db.tasks().live().single().status).isEqualTo(TaskStatus.COMPLETED)
assertThat(afterWrites).isEqualTo(1)
}
// --- review regressions -----------------------------------------------------
@Test
fun aBatchDeletingTwoPropertiesDeletesThoseTwo() {
val a = insert(Tasks.TITLE to "a")
val b = insert(Tasks.TITLE to "b")
listOf(5, 10).forEach { db.alarms().insert(TaskAlarmEntity(taskId = a, minutesBefore = it)) }
db.alarms().insert(TaskAlarmEntity(taskId = b, minutesBefore = 15))
val ids = source.rows(ProviderTable.PROPERTIES, listOf(Properties.PROPERTY_ID), "${Properties.TASK_ID} = $a", null)
.map { it.single() as Long }
writer.batch { ids.forEach { writer.delete(ProviderTable.PROPERTIES, byId(ProviderTable.PROPERTIES, it), null) } }
assertThat(db.alarms().forTask(a)).isEmpty()
assertThat(db.alarms().forTask(b).map { it.minutesBefore }).containsExactly(15)
}
@Test
fun aBatchDeletingTwoOccurrencesDeletesThoseTwo() {
dailySeries()
val first = instanceId(today)
val second = instanceId(today + 1.days)
writer.batch {
writer.delete(ProviderTable.INSTANCES, byId(ProviderTable.INSTANCES, first), null)
writer.delete(ProviderTable.INSTANCES, byId(ProviderTable.INSTANCES, second), null)
}
assertThat(instances().map { it.first }).containsExactly(
(today - 1.days).toEpochMilliseconds(),
(today + 2.days).toEpochMilliseconds(),
(today + 3.days).toEpochMilliseconds(),
)
}
@Test
fun anInstanceIdOutlivesAnotherOccurrencesDeletion() {
dailySeries()
val later = instanceId(today + 2.days)
writer.delete(ProviderTable.INSTANCES, byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(instanceId(today + 2.days)).isEqualTo(later)
assertThat(writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, later), null))
.isEqualTo(1)
assertThat(instances().single { it.third == TasksContract.STATUS_COMPLETED }.first)
.isEqualTo((today + 2.days).toEpochMilliseconds())
}
@Test
fun aRecurringSubtaskOutsideTheWindowGoesWithItsParent() {
val parent = insert(Tasks.TITLE to "parent")
val longAgo = today - 1000.days
val subtask = db.tasks().insert(
TaskEntity(listId = home, uid = "old", title = "old", dtstart = longAgo, rrule = "FREQ=DAILY;COUNT=2",
timezone = "UTC", parentId = parent),
)
writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, parent), null)
assertThat(task(subtask)).isNull()
}
@Test
fun anOverriddenRecurringSubtaskGoesWholeWithItsParent() {
val parent = insert(Tasks.TITLE to "parent")
val subtask = dailySeries("sub")
db.tasks().update(task(subtask)!!.copy(parentId = parent))
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, parent), null)
assertThat(task(subtask)).isNull()
assertThat(db.tasks().live()).isEmpty()
}
@Test
fun aModifiedOccurrenceCanBeWrittenBackAsRead() {
val series = dailySeries()
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
val override = db.tasks().overrides(series).single().id
val row = readRow(ProviderTable.TASKS, override)
writer.update(ProviderTable.TASKS, row + (Tasks.TITLE to "Renamed"), byId(ProviderTable.TASKS, override), null)
assertThat(task(override)!!.title).isEqualTo("Renamed")
}
@Test
fun anInstanceCanBeWrittenBackAsReadToCompleteIt() {
val series = dailySeries()
val instance = instanceId(today)
val row = readRow(ProviderTable.INSTANCES, instance)
writer.update(ProviderTable.INSTANCES, row + (Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instance), null)
assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.COMPLETED)
assertThat(task(series)!!.dtstart).isEqualTo(today - 1.days)
}
@Test
fun movingASubtreeMovesItsSubtasks() {
val work = synced()
val parent = insert(Tasks.TITLE to "parent")
val child = insert(Tasks.TITLE to "child", Tasks.PARENT_ID to parent)
val grandchild = insert(Tasks.TITLE to "grandchild", Tasks.PARENT_ID to child)
writer.update(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to work), byId(ProviderTable.TASKS, parent), null)
assertThat(listOf(parent, child, grandchild).map { task(it)!!.listId }).containsExactly(work, work, work)
assertThat(task(child)!!.parentId).isEqualTo(parent)
assertThat(task(grandchild)!!.parentId).isEqualTo(child)
}
@Test
fun aMoveOntoATakenUidIsRejected() {
val work = synced()
insert(Tasks.LIST_ID to work, Tasks.UID to "same")
val id = insert(Tasks.UID to "same")
assertThrows(IllegalArgumentException::class.java) {
writer.update(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to work), byId(ProviderTable.TASKS, id), null)
}
assertThat(task(id)!!.listId).isEqualTo(home)
}
@Test
fun contentOnAModifiedOccurrenceStillEditsTheSeries() {
val series = dailySeries()
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
val override = db.tasks().overrides(series).single().id
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.TITLE to "Renamed"), byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(task(series)!!.title).isEqualTo("Renamed")
assertThat(task(override)!!.title).isEqualTo("Renamed")
assertThat(task(override)!!.status).isEqualTo(TaskStatus.COMPLETED)
}
@Test
fun aWriteThatChangesNothingOwesNoSync() {
val id = insert(Tasks.TITLE to "t")
db.tasks().update(task(id)!!.copy(isDirty = false))
val modified = task(id)!!.lastModified
writer.update(ProviderTable.TASKS, readRow(ProviderTable.TASKS, id), byId(ProviderTable.TASKS, id), null)
writer.update(ProviderTable.TASKS, emptyMap(), byId(ProviderTable.TASKS, id), null)
assertThat(task(id)!!.isDirty).isFalse()
assertThat(task(id)!!.lastModified).isEqualTo(modified)
}
@Test
fun reopeningAnOpenOccurrenceForksNothing() {
val series = dailySeries()
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 0), byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(db.tasks().overrides(series)).isEmpty()
}
@Test
fun aWriteBeforeStartupSettlesIsRefused() {
val stuck = ProviderSnapshotSource(context, db, resolver, awaitStartup = { awaitCancellation() }, startupWait = 50.milliseconds)
val early = ProviderWriter(db, RoomTasksDataSource(db), stuck, resolver, afterWrite = {})
assertThrows(IllegalStateException::class.java) { early.insert(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to home)) }
assertThat(db.tasks().live()).isEmpty()
}
@Test
fun aSingleRowWriteNeedsNoSnapshot() {
val id = insert(Tasks.TITLE to "t")
val unknown = 987_654L
assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), byId(ProviderTable.TASKS, unknown), null)).isEqualTo(0)
assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), byId(ProviderTable.TASKS, id), null)).isEqualTo(1)
assertThat(writer.update(ProviderTable.INSTANCES, mapOf(Tasks.TITLE to "x"), byId(ProviderTable.INSTANCES, unknown), null)).isEqualTo(0)
assertThat(writer.delete(ProviderTable.PROPERTIES, byId(ProviderTable.PROPERTIES, unknown), null)).isEqualTo(0)
}
// --- second review regressions -----------------------------------------------
@Test
fun tickingOffAModifiedOccurrenceAsReadLeavesTheSeriesAlone() {
val series = dailySeries("Standup")
// Reopening forks an override, which then gets its own title as the app's "this occurrence" edit would.
store().setCompletedInstance(series, today, false)
val override = db.tasks().overrides(series).single()
db.tasks().update(override.copy(title = "Standup (moved room)"))
val row = readRow(ProviderTable.INSTANCES, instanceId(today))
writer.update(ProviderTable.INSTANCES, row + (Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(task(series)!!.title).isEqualTo("Standup")
assertThat(task(override.id)!!.title).isEqualTo("Standup (moved room)")
assertThat(task(override.id)!!.status).isEqualTo(TaskStatus.COMPLETED)
}
@Test
fun anInstanceWrittenBackWithOnlyProgressChangedIsCompleted() {
val series = dailySeries()
val row = readRow(ProviderTable.INSTANCES, instanceId(today))
writer.update(ProviderTable.INSTANCES, row + (Tasks.PERCENT_COMPLETE to 100), byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.COMPLETED)
}
@Test
fun aBadZoneOnAnInstanceReportsTheZone() {
dailySeries()
val instance = instanceId(today)
val error = assertThrows(IllegalArgumentException::class.java) {
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.TZ to "Europe/Berln"), byId(ProviderTable.INSTANCES, instance), null)
}
assertThat(error).hasMessageThat().contains("Unknown time zone")
}
@Test
fun anInProcessSeriesCanBeWrittenBackAsRead() {
val series = dailySeries()
db.tasks().update(task(series)!!.copy(status = TaskStatus.IN_PROCESS, percentComplete = 50))
val row = readRow(ProviderTable.INSTANCES, instanceId(today))
writer.update(ProviderTable.INSTANCES, row + (Tasks.TITLE to "Renamed"), byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(task(series)!!.title).isEqualTo("Renamed")
assertThat(task(series)!!.status).isEqualTo(TaskStatus.IN_PROCESS)
assertThat(db.tasks().overrides(series)).isEmpty()
}
@Test
fun aFloatingSeriesCanBeWrittenBackAsRead() {
val series = db.tasks().insert(
TaskEntity(listId = home, uid = "f", title = "Floating", due = today, rrule = "FREQ=DAILY;COUNT=3", timezone = null),
)
val row = readRow(ProviderTable.INSTANCES, instanceId(today))
writer.update(ProviderTable.INSTANCES, row + (Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "Still floating"), byId(ProviderTable.TASKS, series), null)
assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.COMPLETED)
assertThat(task(series)!!.timezone).isNull()
}
@Test
fun aParentLoopDeletesInsteadOfRecursingForever() {
val a = insert(Tasks.TITLE to "a")
val b = insert(Tasks.TITLE to "b", Tasks.PARENT_ID to a)
db.tasks().update(task(a)!!.copy(parentId = b))
writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, a), null)
assertThat(task(a)).isNull()
assertThat(task(b)).isNull()
}
@Test
fun aBulkMoveWorksWhicheverOrderTheRowsComeIn() {
val work = synced()
// The subtask gets the lower id, so it comes first unless the writer reorders.
val child = insert(Tasks.TITLE to "child")
val parent = insert(Tasks.TITLE to "parent")
db.tasks().update(task(child)!!.copy(parentId = parent))
val count = writer.update(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to work), "${Tasks.LIST_ID} = ?", arrayOf(home.toString()))
assertThat(count).isEqualTo(2)
assertThat(task(child)!!.listId).isEqualTo(work)
assertThat(task(parent)!!.listId).isEqualTo(work)
assertThat(task(child)!!.parentId).isEqualTo(parent)
}
@Test
fun aRowSelectedWithAnArgumentIsWritten() {
val a = insert(Tasks.TITLE to "a")
val b = insert(Tasks.TITLE to "b")
assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), "${Tasks.ID}=?", arrayOf(b.toString()))).isEqualTo(1)
assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), "${Tasks.ID} = ?", arrayOf("987654"))).isEqualTo(0)
assertThat(task(a)!!.title).isEqualTo("a")
assertThat(task(b)!!.title).isEqualTo("x")
}
@Test
fun garbageExceptionDatesAreRejected() {
val series = dailySeries()
assertThrows(IllegalArgumentException::class.java) {
writer.update(ProviderTable.TASKS, mapOf(Tasks.EXDATE to "tomorrow"), byId(ProviderTable.TASKS, series), null)
}
assertThat(task(series)!!.exdate).isNull()
}
private fun store() = RoomTasksDataSource(db)
/** Every column of one published row, as a client that read it would write it back. */
private fun readRow(table: ProviderTable, id: Long): Map<String, Any?> {
val columns = table.columns.map { it.first }
val row = source.rows(table, columns, byId(table, id), null).single()
return columns.zip(row).toMap() - table.idColumn
}
}
private object NoProvider : ProviderEnvironment {
override fun packageDeclaring(authority: String): String? = null
override fun isGranted(permission: String): Boolean = false
override fun appLabel(packageName: String): String? = null
}
@@ -0,0 +1,141 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
import android.database.Cursor
import android.os.Build
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import org.junit.Assert.assertThrows
import org.junit.Assume.assumeTrue
import org.junit.Test
import org.junit.runner.RunWith
/** The SQL a client sends runs against the snapshot as it would against OpenTasks. */
@RunWith(AndroidJUnit4::class)
class SnapshotDatabaseTest {
private val db = SnapshotDatabase()
private fun task(id: Long, title: String?, due: Long? = null): ProviderRow =
mapOf(Tasks.ID to id, Tasks.LIST_ID to 1L, Tasks.TITLE to title, Tasks.DUE to due, Tasks.PRIORITY to 0)
private fun load(vararg tasks: ProviderRow, lists: List<ProviderRow> = emptyList()) = db.load(
ProviderSnapshot(
ProviderTable.entries.associateWith {
when (it) {
ProviderTable.TASKS -> tasks.toList()
ProviderTable.LISTS -> lists
else -> emptyList()
}
},
),
)
private fun Cursor.strings(column: String): List<String?> = use {
val index = getColumnIndexOrThrow(column)
buildList { while (moveToNext()) add(if (isNull(index)) null else getString(index)) }
}
@Test
fun aNullProjectionReturnsEveryColumnInTableOrder() {
load(task(1, "a"))
db.query(ProviderTable.TASKS, null, null, null, null).use {
assertThat(it.columnNames.toList()).containsExactlyElementsIn(ProviderTable.TASKS.columns.map { c -> c.first })
.inOrder()
assertThat(it.count).isEqualTo(1)
}
}
@Test
fun selectionArgumentsAndSortOrderApply() {
load(task(1, "b", due = 30), task(2, "a", due = 10), task(3, "c", due = 20), task(4, "skip", due = 5))
val titles = db.query(ProviderTable.TASKS, arrayOf(Tasks.TITLE), "${Tasks.TITLE} != ?", arrayOf("skip"), "${Tasks.DUE} ASC")
.strings(Tasks.TITLE)
assertThat(titles).containsExactly("a", "c", "b").inOrder()
}
@Test
fun typesSurviveTheCopy() {
load(task(1, null, due = 1_700_000_000_000))
db.query(ProviderTable.TASKS, arrayOf(Tasks.TITLE, Tasks.DUE, Tasks.PRIORITY), null, null, null).use {
it.moveToFirst()
assertThat(it.isNull(0)).isTrue()
assertThat(it.getType(1)).isEqualTo(Cursor.FIELD_TYPE_INTEGER)
assertThat(it.getLong(1)).isEqualTo(1_700_000_000_000)
assertThat(it.getInt(2)).isEqualTo(0)
}
}
@Test
fun anUnsupportedColumnReadsAsNull() {
load(task(1, "a"))
assertThat(db.query(ProviderTable.TASKS, arrayOf("_sync_id"), null, null, null).strings("_sync_id"))
.containsExactly(null)
}
@Test
fun anUnknownProjectionColumnIsRejected() {
load(task(1, "a"))
assertThrows(IllegalArgumentException::class.java) {
db.query(ProviderTable.TASKS, arrayOf("no_such_column"), null, null, null)
}
}
@Test
fun anUnknownSelectionColumnIsRejected() {
assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
load(task(1, "a"))
assertThrows(IllegalArgumentException::class.java) {
db.query(ProviderTable.TASKS, null, "sqlite_master.name IS NOT NULL", null, null)
}
}
@Test
fun aSelectionCannotBreakOutOfItsWhereClause() {
load(task(1, "a"))
assertThrows(IllegalArgumentException::class.java) {
db.query(ProviderTable.TASKS, null, "1) UNION SELECT name FROM sqlite_master --", null, null)
}
}
@Test
fun eachTableAnswersOnlyForItsOwnRows() {
load(task(1, "a"), lists = listOf(mapOf(Lists.ID to 7L, Lists.NAME to "Home")))
assertThat(db.query(ProviderTable.LISTS, arrayOf(Lists.NAME), null, null, null).strings(Lists.NAME))
.containsExactly("Home")
db.query(ProviderTable.INSTANCES, null, null, null, null).use { assertThat(it.count).isEqualTo(0) }
}
@Test
fun aReloadReplacesTheRowsButNotAnOpenCursor() {
load(task(1, "old"))
val before = db.query(ProviderTable.TASKS, arrayOf(Tasks.TITLE), null, null, null)
load(task(2, "new"))
assertThat(before.strings(Tasks.TITLE)).containsExactly("old")
assertThat(db.query(ProviderTable.TASKS, arrayOf(Tasks.TITLE), null, null, null).strings(Tasks.TITLE))
.containsExactly("new")
}
@Test
fun anEmptySnapshotEmptiesEveryTable() {
load(task(1, "a"))
db.load(ProviderSnapshot.EMPTY)
ProviderTable.entries.forEach { table ->
db.query(table, null, null, null, null).use { assertThat(it.count).isEqualTo(0) }
}
}
}
@@ -539,4 +539,47 @@ class RoomTasksDataSourceTest {
assertThat(thrown).isNotNull()
}
@Test
fun subtaskIdsNameRowsNotOccurrences() {
val parent = source.insertTask(form("parent"))
val longAgo = now - 1000.days
val outOfWindow = db.tasks().insert(
TaskEntity(listId = listId, uid = "old", title = "old", dtstart = longAgo, rrule = "FREQ=DAILY;COUNT=2", parentId = parent),
)
val series = db.tasks().insert(
TaskEntity(listId = listId, uid = "s", title = "s", dtstart = now, rrule = "FREQ=DAILY;COUNT=3", parentId = parent),
)
source.setCompletedInstance(series, now, true)
val plain = source.insertTask(form("plain").copy(parentId = parent))
assertThat(source.subtaskIds(parent)).containsExactly(outOfWindow, series, plain)
}
@Test
fun deletingASubtreeEndsOnAParentLoop() {
val a = source.insertTask(form("a"))
val b = source.insertTask(form("b").copy(parentId = a))
val c = source.insertTask(form("c").copy(parentId = b))
db.tasks().update(db.tasks().entity(a)!!.copy(parentId = c))
source.deleteWithSubtasks(a)
assertThat(listOf(a, b, c).mapNotNull { db.tasks().entity(it) }).isEmpty()
}
@Test
fun deletingFollowingFromTheFirstOccurrenceTakesTheWholeSubtree() {
val start = now
val series = db.tasks().insert(
TaskEntity(listId = listId, uid = "s", title = "s", dtstart = start, rrule = "FREQ=DAILY;COUNT=3"),
)
val child = source.insertTask(form("child").copy(parentId = series))
val grandchild = source.insertTask(form("grandchild").copy(parentId = child))
source.deleteFollowing(series, start)
assertThat(listOf(series, child, grandchild).mapNotNull { db.tasks().entity(it) }).isEmpty()
}
}
@@ -8,27 +8,7 @@ import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* The migration harness, proven against the committed schema in `app/schemas/`.
*
* There is one schema version today, so all there is to assert is that the helper
* can build v1 from the exported JSON, seed it, and validate it back — i.e. the
* export, the assets wiring and the identity hash all line up. That is the point:
* the first real migration only has to add its own case.
*
* **Adding a v1 → v2 case.** When sync adds columns, bump [TasksDatabase]'s
* `version`, let KSP export `2.json`, declare the `Migration(1, 2)` next to the
* database, and add a test here shaped like this:
*
* ```
* helper.createDatabase(TEST_DB, 1).use { db ->
* db.execSQL("INSERT INTO task_lists (name, color) VALUES ('Groceries', 0)")
* }
* helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2).use { db ->
* // read the seeded rows back — validation proves the shape, not the data
* }
* ```
*/
/** Migrations, proven against the committed schemas in `app/schemas/`. */
@RunWith(AndroidJUnit4::class)
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 {
const val TEST_DB = "migration-test.db"
}
@@ -107,6 +107,17 @@ class ExternalImportTest {
assertThat(importer.hasRun.first()).isTrue()
}
@Test
fun copiesTagsCleanedUp() = runBlocking {
source.lists = listOf(list(7, "Errands"))
source.tasks = mapOf(7L to listOf(task(100, "Milk", tags = listOf("Shop", " Shop ", "", "Home"))))
importer.run()
assertThat(db.tasks().tasks(listId = null, includeCompleted = true).single().task.categories)
.containsExactly("Shop", "Home").inOrder()
}
/** Every list arrives device-only: the account belongs to the sync app. */
@Test
fun importedListsAreDeviceOnly() = runBlocking {
@@ -241,6 +252,7 @@ class ExternalImportTest {
title: String,
uid: String? = "uid-$id",
parentId: Long? = null,
tags: List<String> = emptyList(),
) = ExportTask(
taskId = id,
uid = uid,
@@ -260,6 +272,7 @@ class ExternalImportTest {
rrule = null,
rdate = null,
parentId = parentId,
tags = tags,
)
private companion object {
+25 -2
View File
@@ -9,13 +9,26 @@
selected External mode. Both are dangerous-level.
StorageMode.OWN needs nothing here: it is a Room database in our own data
directory. Agendula publishes no ContentProvider and declares no
permissions of its own. -->
directory. -->
<uses-permission android:name="org.dmfs.permission.READ_TASKS" />
<uses-permission android:name="org.dmfs.permission.WRITE_TASKS" />
<uses-permission android:name="org.tasks.permission.READ_TASKS" />
<uses-permission android:name="org.tasks.permission.WRITE_TASKS" />
<!-- What another app requests to read or write the own store through
AgendulaTaskProvider. Dangerous, like OpenTasks' own: the user grants
them per app. Suffixed per build, so debug and release can coexist. -->
<permission
android:name="${applicationId}.permission.READ_TASKS"
android:description="@string/permission_read_tasks_description"
android:label="@string/permission_read_tasks_label"
android:protectionLevel="dangerous" />
<permission
android:name="${applicationId}.permission.WRITE_TASKS"
android:description="@string/permission_write_tasks_description"
android:label="@string/permission_write_tasks_label"
android:protectionLevel="dangerous" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Exact due-time reminders. User-granted: USE_EXACT_ALARM is reserved for
@@ -233,6 +246,16 @@
</intent-filter>
</receiver>
<!-- The own store under the OpenTasks contract. Empty, and refusing
writes, while External mode is active. -->
<provider
android:name=".data.tasks.provider.AgendulaTaskProvider"
android:authorities="${applicationId}.tasks"
android:exported="true"
android:readPermission="${applicationId}.permission.READ_TASKS"
android:writePermission="${applicationId}.permission.WRITE_TASKS"
tools:ignore="SystemPermissionTypo" />
<!-- WorkManager's on-demand initialisation. Removing the default
initializer is what lets AgendulaApp supply a HiltWorkerFactory, so
@HiltWorker workers can take injected dependencies. -->
@@ -16,6 +16,7 @@ import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
import de.jeanlucmakiola.agendula.data.reminders.TaskNotifier
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StartupGate
import de.jeanlucmakiola.agendula.data.tasks.provider.ProviderSnapshotSource
import de.jeanlucmakiola.agendula.data.tasks.room.DatabaseCheckpoint
import de.jeanlucmakiola.agendula.widget.TaskWidgetUpdater
import de.jeanlucmakiola.floret.crash.CrashConfig
@@ -87,6 +88,7 @@ class AgendulaApp : Application(), Configuration.Provider {
scheduler.sync()
}
runCatching { entryPoint.taskWidgetUpdater().start() }
runCatching { entryPoint.providerSnapshotSource().start() }
runCatching { ReminderMaintenanceWorker.schedule(this@AgendulaApp) }
}
}
@@ -122,5 +124,6 @@ class AgendulaApp : Application(), Configuration.Provider {
fun taskNotifier(): TaskNotifier
fun channelRefreshers(): Set<@JvmSuppressWildcards ChannelRefresher>
fun taskWidgetUpdater(): TaskWidgetUpdater
fun providerSnapshotSource(): ProviderSnapshotSource
}
}
@@ -149,6 +149,8 @@ class MainActivity : ComponentActivity() {
const val ACTION_TODAY = "de.jeanlucmakiola.agendula.action.TODAY"
private const val ACTION_OPEN_SMART = "de.jeanlucmakiola.agendula.action.OPEN_SMART"
private const val ACTION_OPEN_LIST = "de.jeanlucmakiola.agendula.action.OPEN_LIST"
private const val ACTION_OPEN_FILTER = "de.jeanlucmakiola.agendula.action.OPEN_FILTER"
private const val EXTRA_FILTER_ID = "de.jeanlucmakiola.agendula.extra.FILTER_ID"
private const val EXTRA_SMART_LIST = "de.jeanlucmakiola.agendula.extra.SMART_LIST"
private const val EXTRA_LIST_ID = "de.jeanlucmakiola.agendula.extra.LIST_ID"
private const val EXTRA_PRESET_LIST_ID = "de.jeanlucmakiola.agendula.extra.PRESET_LIST_ID"
@@ -198,6 +200,12 @@ class MainActivity : ComponentActivity() {
.putExtra(EXTRA_LIST_ID, listId)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
/** Opens one saved filter. */
fun filterIntent(context: Context, filterId: Long): Intent =
Intent(ACTION_OPEN_FILTER, "agendula://filter/$filterId".toUri(), context, MainActivity::class.java)
.putExtra(EXTRA_FILTER_ID, filterId)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
internal fun navRequestOf(intent: Intent?): NavRequest? {
if (intent == null) return null
if (BuildConfig.SYNC_ENABLED) {
@@ -215,6 +223,8 @@ class MainActivity : ComponentActivity() {
?.let { return NavRequest.OpenSmart(it) }
ACTION_OPEN_LIST -> intent.getLongExtra(EXTRA_LIST_ID, -1L).takeIf { it > 0L }
?.let { return NavRequest.OpenList(it) }
ACTION_OPEN_FILTER -> intent.getLongExtra(EXTRA_FILTER_ID, -1L).takeIf { it > 0L }
?.let { return NavRequest.OpenFilter(it) }
}
importIntentUri(intent)?.let { return NavRequest.Import(it) }
if (intent.action == Intent.ACTION_SEND && intent.type?.startsWith("text/plain") == true) {
@@ -89,6 +89,7 @@ object DataProvideModule {
// backed-up `.db` does not. The backup rules carry all three files and
// the app checkpoints on ON_STOP.
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
.addMigrations(TasksDatabase.MIGRATION_1_2)
.build()
/**
@@ -0,0 +1,83 @@
package de.jeanlucmakiola.agendula.data.prefs
import de.jeanlucmakiola.agendula.domain.DueWindow
import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.agendula.domain.SavedFilter
import de.jeanlucmakiola.agendula.domain.TagMatch
import java.net.URLDecoder
import java.net.URLEncoder
/**
* Stored form of the saved filters: one line per filter,
* `id|name|due|lists|tags|match|priority`, with name and tags URL-encoded and
* list ids qualified by the store they belong to (`OWN:3`), since each store
* numbers its lists on its own. Fixed once released — don't change without a
* data migration.
*/
internal object SavedFilterCodec {
/** The filters in [raw], with only the list ids of [mode]'s store. */
fun decode(raw: String?, mode: String): List<SavedFilter> =
lines(raw).mapNotNull { runCatching { decodeLine(it, mode) }.getOrNull() }
/** [raw] with [filter] replacing the line of the same id, or appended. */
fun upsert(raw: String?, filter: SavedFilter, mode: String): String {
val existing = lines(raw)
val index = existing.indexOfFirst { idOf(it) == filter.id }
// Keep the lists another store contributed, so switching stores and back
// doesn't widen a filter that was edited in between, unless "any list"
// was chosen here, which drops them on purpose.
val foreign = if (filter.anyList) {
emptyList()
} else {
existing.getOrNull(index)?.let(::fields)?.getOrNull(3)
?.let(::splitList)?.filterNot { it.startsWith("$mode:") }.orEmpty()
}
val line = encodeLine(filter, mode, foreign)
val updated = if (index >= 0) existing.toMutableList().also { it[index] = line } else existing + line
return updated.joinToString("\n")
}
fun remove(raw: String?, id: Long): String = lines(raw).filterNot { idOf(it) == id }.joinToString("\n")
private fun lines(raw: String?): List<String> = raw.orEmpty().split('\n').filter { it.isNotBlank() }
private fun fields(line: String): List<String> = line.split('|')
private fun idOf(line: String): Long? = fields(line).firstOrNull()?.toLongOrNull()
private fun splitList(value: String): List<String> = value.split(',').filter { it.isNotEmpty() }
private fun encodeLine(filter: SavedFilter, mode: String, foreignLists: List<String>): String =
listOf(
filter.id.toString(),
encode(filter.name),
filter.due.sorted().joinToString(",") { it.name },
(filter.listIds.sorted().map { "$mode:$it" } + foreignLists).joinToString(","),
filter.tags.sorted().joinToString(",") { encode(it) },
filter.tagMatch.name,
filter.minPriority.name,
).joinToString("|")
private fun decodeLine(line: String, mode: String): SavedFilter? {
val f = fields(line)
if (f.size < 7) return null
val id = f[0].toLongOrNull() ?: return null
val lists = splitList(f[3])
val here = lists.filter { it.startsWith("$mode:") }.mapNotNull { it.removePrefix("$mode:").toLongOrNull() }.toSet()
return SavedFilter(
id = id,
name = decode(f[1]),
due = splitList(f[2]).mapNotNull { runCatching { DueWindow.valueOf(it) }.getOrNull() }.toSet(),
listIds = here,
listsElsewhere = lists.any { !it.startsWith("$mode:") },
tags = splitList(f[4]).map(::decode).toSet(),
tagMatch = runCatching { TagMatch.valueOf(f[5]) }.getOrDefault(TagMatch.ANY),
minPriority = runCatching { Priority.valueOf(f[6]) }.getOrDefault(Priority.NONE),
)
}
private fun encode(value: String): String = URLEncoder.encode(value, Charsets.UTF_8.name())
private fun decode(value: String): String = URLDecoder.decode(value, Charsets.UTF_8.name())
}
@@ -10,6 +10,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import de.jeanlucmakiola.agendula.domain.SavedFilter
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskFormField
@@ -20,6 +21,7 @@ import de.jeanlucmakiola.floret.reminders.applyReminderOverride
import de.jeanlucmakiola.floret.reminders.normalizeReminders
import de.jeanlucmakiola.floret.reminders.reminderLeadsFor
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import java.time.DayOfWeek
import kotlinx.coroutines.flow.map
import javax.inject.Inject
@@ -259,6 +261,34 @@ class SettingsPrefs @Inject constructor(
p[SMART_LIST_HIDDEN] = if (hidden) current + entry else current - entry
}
/** The saved filters, oldest first, with the list ids of the current store. */
val savedFilters: Flow<List<SavedFilter>> =
dataStore.data.map { p -> p[SAVED_FILTERS] to p[STORAGE_MODE] }
.distinctUntilChanged()
.map { (raw, mode) -> SavedFilterCodec.decode(raw, (storedMode(mode) ?: resolver.autoMode()).name) }
.distinctUntilChanged()
/** One saved filter; `null` while it doesn't exist. */
fun savedFilter(id: Long): Flow<SavedFilter?> =
savedFilters.map { all -> all.firstOrNull { it.id == id } }.distinctUntilChanged()
/** Store [filter]; an id of 0 makes a new one. Returns the filter's id. */
suspend fun saveFilter(filter: SavedFilter): Long {
var id = filter.id
dataStore.edit { p ->
if (id <= 0) {
id = p[SAVED_FILTER_NEXT_ID] ?: 1L
p[SAVED_FILTER_NEXT_ID] = id + 1
}
p[SAVED_FILTERS] = SavedFilterCodec.upsert(p[SAVED_FILTERS], filter.copy(id = id), modeOf(p).name)
}
return id
}
suspend fun deleteFilter(id: Long) = dataStore.edit { p ->
p[SAVED_FILTERS] = SavedFilterCodec.remove(p[SAVED_FILTERS], id)
}
suspend fun setDefaultEditFields(fields: Set<TaskFormField>) = dataStore.edit {
it[DEFAULT_EDIT_FIELDS] = fields.mapTo(mutableSetOf()) { field -> field.name }
}
@@ -288,6 +318,8 @@ class SettingsPrefs @Inject constructor(
val PUSH_ENABLED = booleanPreferencesKey("push_enabled")
val SMART_LIST_HIDDEN = stringSetPreferencesKey("smart_list_hidden")
val DEFAULT_START_TODAY = booleanPreferencesKey("default_start_today")
val SAVED_FILTERS = stringPreferencesKey("saved_filters")
val SAVED_FILTER_NEXT_ID = longPreferencesKey("saved_filter_next_id")
}
}
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.agendula.data.tasks
import android.content.ContentProviderOperation
import android.content.ContentResolver
import android.content.ContentUris
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.TaskList
import de.jeanlucmakiola.agendula.domain.export.ExportTask
import de.jeanlucmakiola.agendula.domain.normalizeTags
import de.jeanlucmakiola.agendula.domain.recurrence.ExpansionWindow
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
@@ -83,6 +85,27 @@ class AndroidTasksDataSource @Inject constructor(
override fun subtasks(parentTaskId: Long): List<Task> =
queryInstances("${Tasks.PARENT_ID} = ?", arrayOf(parentTaskId.toString()))
/**
* Read from the `tasks` table, so a recurring subtask with no occurrence in
* the window still counts. All columns and a lenient `_deleted`, as in
* [exportTasks], since the table's shape varies across providers.
*/
override fun subtaskIds(parentTaskId: Long): List<Long> = resolver.query(
TasksContract.tasksUri(authority()),
null,
"${Tasks.PARENT_ID} = ? AND (${Tasks.DELETED} IS NULL OR ${Tasks.DELETED} = 0)",
arrayOf(parentTaskId.toString()),
null,
)?.use { c ->
val r = CursorColumnReader(c)
buildList {
while (c.moveToNext()) {
if (r.getLong(Tasks.ORIGINAL_INSTANCE_ID) != null) continue
r.getLong(Tasks.ID)?.takeIf { it != parentTaskId }?.let(::add)
}
}
} ?: emptyList()
private fun queryInstances(selection: String?, args: Array<String>?): List<Task> {
val uri = TasksContract.instancesUri(authority())
// projection = null (all columns): the instances view differs across
@@ -90,11 +113,14 @@ class AndroidTasksDataSource @Inject constructor(
// `is_recurring` / `distance_from_current`). The by-name mapper reads
// whatever is present and nulls the rest; the repository re-sorts, so we
// don't depend on a provider sort column either.
return resolver.query(uri, null, selection, args, null)
val tasks = resolver.query(uri, null, selection, args, null)
?.use { c ->
val reader = CursorColumnReader(c)
buildList { while (c.moveToNext()) add(TaskMapper.task(reader)) }
} ?: emptyList()
if (tasks.isEmpty()) return tasks
val tags = categories(tasks.map { it.taskId })
return tasks.map { task -> tags[task.taskId]?.let { task.copy(tags = it) } ?: task }
}
override fun exportTasks(listId: Long): List<ExportTask> {
@@ -102,7 +128,7 @@ class AndroidTasksDataSource @Inject constructor(
// table's shape varies across provider versions, and the by-name mapper
// reads what's there.
val uri = TasksContract.tasksUri(authority())
return resolver.query(
val tasks = resolver.query(
uri,
null,
// _deleted marks a row awaiting a sync round-trip. It's gone as far as
@@ -115,21 +141,39 @@ class AndroidTasksDataSource @Inject constructor(
val reader = CursorColumnReader(c)
buildList { while (c.moveToNext()) add(TaskMapper.exportTask(reader)) }
} ?: emptyList()
if (tasks.isEmpty()) return tasks
val tags = categories(tasks.map { it.taskId })
return tasks.map { task -> tags[task.taskId]?.let { task.copy(tags = it) } ?: task }
}
// --- writes ---------------------------------------------------------------
override fun insertTask(form: TaskForm): Long {
val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id)
val uri = resolver.insert(TasksContract.tasksUri(authority()), values.toContentValues())
?: throw TaskWriteFailedException("insert task")
return uri.lastPathSegment?.toLongOrNull() ?: throw TaskWriteFailedException("insert task: no id")
// One batch, so a refused tag leaves no task behind for a retry to duplicate.
val authority = authority()
val ops = arrayListOf(
ContentProviderOperation.newInsert(TasksContract.tasksUri(authority))
.withValues(values.toContentValues())
.build(),
)
normalizeTags(form.tags).forEach { tag ->
ops += ContentProviderOperation.newInsert(TasksContract.propertiesUri(authority))
.withValues(TaskWriteMapper.categoryValues(0, tag).toContentValues().apply { remove(Properties.TASK_ID) })
.withValueBackReference(Properties.TASK_ID, 0)
.build()
}
val results = runCatching { resolver.applyBatch(authority, ops) }
.getOrElse { throw TaskWriteFailedException("insert task", it) }
return results.firstOrNull()?.uri?.lastPathSegment?.toLongOrNull()
?: throw TaskWriteFailedException("insert task: no id")
}
override fun updateTask(taskId: Long, form: TaskForm) {
val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id)
val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("update task $taskId")
setCategories(taskId, form.tags)
}
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) {
@@ -151,6 +195,7 @@ class AndroidTasksDataSource @Inject constructor(
(Tasks.DUE to shifted(anchors?.second, occurrence?.due, form.due))
val rows = resolver.update(taskUri(authority(), seriesId), values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("update series $seriesId")
setCategories(seriesId, form.tags)
}
/**
@@ -177,7 +222,7 @@ class AndroidTasksDataSource @Inject constructor(
override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) {
val spec = seriesSpec(seriesId) ?: return
if (spec.rrule == null || occurrenceStart <= spec.anchor) return deleteTask(seriesId)
if (spec.rrule == null || occurrenceStart <= spec.anchor) return deleteWithSubtasks(seriesId)
endSeriesBefore(seriesId, spec, occurrenceStart)
}
@@ -243,8 +288,28 @@ class AndroidTasksDataSource @Inject constructor(
?: throw TaskWriteFailedException("update instance $taskId@$occurrenceStart: no such occurrence")
val values = TaskWriteMapper.instanceValues(form, ZoneId.systemDefault().id)
val uri = TasksContract.instanceUri(authority(), instanceId)
val rows = resolver.update(uri, values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("update instance $instanceId")
// The fork copies the series' properties onto the override, so the tags
// go onto the series first and are put back once the override exists.
// Unchanged tags are inherited as they are, and the series is left alone.
val seriesTags = categories(listOf(taskId))[taskId].orEmpty()
val retag = normalizeTags(form.tags) != seriesTags
if (retag) setCategories(taskId, form.tags)
var failure: Throwable? = null
try {
val rows = resolver.update(uri, values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("update instance $instanceId")
} catch (e: Throwable) {
failure = e
throw e
} finally {
if (retag) {
try {
setCategories(taskId, seriesTags)
} catch (e: Throwable) {
failure?.addSuppressed(e) ?: throw e
}
}
}
}
/**
@@ -348,6 +413,58 @@ class AndroidTasksDataSource @Inject constructor(
} ?: emptyList()
}
/** Tags by task id for [taskIds], in stored order. */
private fun categories(taskIds: Collection<Long>): Map<Long, List<String>> {
val rows = taskIds.distinct().chunked(MAX_SQL_ARGS - 1).flatMap { chunk ->
val selection = "${Properties.MIMETYPE} = ? AND ${Properties.TASK_ID} IN (${chunk.joinToString(",") { "?" }})"
val args = (listOf(TasksContract.Category.MIMETYPE) + chunk.map(Long::toString)).toTypedArray()
resolver.query(
TasksContract.propertiesUri(authority()),
arrayOf(Properties.TASK_ID, TasksContract.Category.NAME),
selection,
args,
Properties.PROPERTY_ID,
)?.use { c ->
val reader = CursorColumnReader(c)
buildList {
while (c.moveToNext()) {
val id = reader.getLong(Properties.TASK_ID) ?: continue
val name = reader.getString(TasksContract.Category.NAME) ?: continue
add(id to name)
}
}
} ?: emptyList()
}
return rows.groupBy({ it.first }, { it.second }).mapValues { normalizeTags(it.value) }
}
/**
* Replaces [taskId]'s category rows in one batch, so a refused insert cannot
* leave half the tags, and leaves them alone when nothing changed so the task
* isn't dirtied.
*/
private fun setCategories(taskId: Long, tags: List<String>) {
val wanted = normalizeTags(tags)
if (categories(listOf(taskId))[taskId].orEmpty() == wanted) return
val authority = authority()
val uri = TasksContract.propertiesUri(authority)
val ops = arrayListOf(
ContentProviderOperation.newDelete(uri)
.withSelection(
"${Properties.TASK_ID} = ? AND ${Properties.MIMETYPE} = ?",
arrayOf(taskId.toString(), TasksContract.Category.MIMETYPE),
)
.build(),
)
wanted.forEach {
ops += ContentProviderOperation.newInsert(uri)
.withValues(TaskWriteMapper.categoryValues(taskId, it).toContentValues())
.build()
}
runCatching { resolver.applyBatch(authority, ops) }
.getOrElse { throw TaskWriteFailedException("set categories for task $taskId", it) }
}
override fun setCancelled(taskId: Long, cancelled: Boolean) {
val rows = resolver.update(taskUri(authority(), taskId), cancelValues(cancelled).toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("cancel task $taskId")
@@ -460,3 +577,6 @@ class AndroidTasksDataSource @Inject constructor(
return cv
}
}
/** SQLite's default bound-argument limit on older Android releases. */
private const val MAX_SQL_ARGS = 999
@@ -5,8 +5,8 @@ class ProviderUnavailableException :
IllegalStateException("No tasks provider installed")
/** A ContentResolver write returned no URI or affected no rows. */
class TaskWriteFailedException(operation: String) :
RuntimeException("Task write failed: $operation")
class TaskWriteFailedException(operation: String, cause: Throwable? = null) :
RuntimeException("Task write failed: $operation", cause)
/**
* The task changed (e.g. a DAVx5 sync or another app) since the edit form loaded
@@ -43,6 +43,8 @@ class ModeRoutingTasksDataSource(
override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) =
active().deleteFollowing(seriesId, occurrenceStart)
override fun subtasks(parentTaskId: Long): List<Task> = active().subtasks(parentTaskId)
override fun subtaskIds(parentTaskId: Long): List<Long> = active().subtaskIds(parentTaskId)
override fun deleteWithSubtasks(taskId: Long) = active().deleteWithSubtasks(taskId)
override fun insertTask(form: TaskForm): Long = active().insertTask(form)
override fun updateTask(taskId: Long, form: TaskForm) = active().updateTask(taskId, form)
@@ -113,6 +113,12 @@ object TaskWriteMapper {
Alarm.ALARM_TYPE to Alarm.TYPE_MESSAGE,
)
fun categoryValues(taskId: Long, name: String): Map<String, Any?> = mapOf(
Properties.TASK_ID to taskId,
Properties.MIMETYPE to TasksContract.Category.MIMETYPE,
TasksContract.Category.NAME to name,
)
/** The user-owned columns of a list — what an edit is allowed to change. */
fun listValues(name: String, color: Int): Map<String, Any?> = mapOf(
Lists.NAME to name.trim(),
@@ -86,6 +86,12 @@ object TasksContract {
const val UID = "_uid"
const val DELETED = "_deleted"
const val HAS_ALARMS = "has_alarms"
const val ORIGINAL_INSTANCE_ALLDAY = "original_instance_allday"
const val LIST_OWNER = "list_owner"
const val LIST_ACCESS_LEVEL = "list_access_level"
const val VISIBLE = "visible"
}
/**
@@ -103,6 +109,7 @@ object TasksContract {
const val INSTANCE_DUE_SORTING = "instance_due_sorting"
const val DISTANCE_FROM_CURRENT = "distance_from_current"
const val IS_RECURRING = "is_recurring"
const val INSTANCE_DURATION = "instance_duration"
/**
* The occurrence's `RECURRENCE-ID` — the time this occurrence was
@@ -118,6 +125,10 @@ object TasksContract {
const val PROPERTY_ID = "property_id"
const val TASK_ID = "task_id"
const val MIMETYPE = "mimetype"
const val DATA0 = "data0"
const val DATA1 = "data1"
const val DATA2 = "data2"
const val DATA3 = "data3"
}
/**
@@ -154,6 +165,21 @@ object TasksContract {
const val TYPE_MESSAGE = 1
}
/**
* A category property row — one tag. DAVx5 maps these to and from
* `CATEGORIES`. Inserting with only [NAME] is enough: the provider looks the
* category up in the task's account, or creates it, and fills in `data0`.
*/
object Category {
const val MIMETYPE = "vnd.android.cursor.item/category"
/** `data1` — the tag as spelled. */
const val NAME = "data1"
/** `data2` — the tag's colour, if any. */
const val COLOR = "data2"
}
fun propertiesUri(authority: String): Uri = "content://$authority/${Properties.PATH}".toUri()
// --- status values (TaskColumns.STATUS_*) --------------------------------
@@ -46,6 +46,29 @@ interface TasksDataSource {
fun task(taskId: Long, occurrenceStart: Instant?): Task? = task(taskId)
fun subtasks(parentTaskId: Long): List<Task>
/**
* The task rows directly under [parentTaskId]: one id per subtask, its series
* master when it recurs, whether or not any occurrence falls in the window
* [subtasks] expands.
*/
fun subtaskIds(parentTaskId: Long): List<Long> =
subtasks(parentTaskId).map { it.seriesId ?: it.taskId }.distinct().filter { it != parentTaskId }
/**
* Delete [taskId] and its subtasks, all the way down; left behind they would
* hang off a tombstone. Each task once, so a parent loop an import brought in
* ends instead of recursing forever.
*/
fun deleteWithSubtasks(taskId: Long) {
val seen = HashSet<Long>()
fun delete(id: Long) {
if (!seen.add(id)) return
subtaskIds(id).forEach(::delete)
deleteTask(id)
}
delete(taskId)
}
fun insertTask(form: TaskForm): Long
fun updateTask(taskId: Long, form: TaskForm)
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.domain.SavedFilter
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskDetail
import de.jeanlucmakiola.agendula.domain.TaskFilter
@@ -13,6 +14,9 @@ import kotlin.time.Instant
/** Whether Agendula can use the tasks provider right now. Drives onboarding. */
enum class ProviderStatus { READY, NEEDS_PERMISSION, NO_PROVIDER }
/** Tasks for a filter; [savedFilter] is a saved filter's definition, `null` once it's deleted. */
data class FilteredTasks(val tasks: List<Task>, val savedFilter: SavedFilter? = null)
/**
* The single entry point the UI layer uses. Flows re-emit automatically when the
* provider changes (our writes *and* external sync like DAVx5), via the
@@ -27,7 +31,10 @@ interface TasksRepository {
*/
fun writableTaskLists(): Flow<List<TaskList>> =
taskLists().map { lists -> lists.filter { it.acceptsWrites } }
fun tasks(filter: TaskFilter): Flow<List<Task>>
fun tasks(filter: TaskFilter): Flow<List<Task>> = filteredTasks(filter).map { it.tasks }
/** [tasks] together with the definition behind a [TaskFilter.Saved]. */
fun filteredTasks(filter: TaskFilter): Flow<FilteredTasks>
/**
* The direct children of [parentId], re-emitting live like the other flows.
@@ -1,7 +1,9 @@
package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.floret.time.DayWindow
import de.jeanlucmakiola.agendula.domain.SavedFilter
import de.jeanlucmakiola.agendula.domain.SeriesCollapse
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskDetail
@@ -17,7 +19,10 @@ import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -32,12 +37,26 @@ class TasksRepositoryImpl @Inject constructor(
private val dataSource: TasksDataSource,
private val providerResolver: ProviderResolver,
private val startupGate: StartupGate,
private val settingsPrefs: SettingsPrefs,
@IoDispatcher private val io: CoroutineDispatcher,
) : TasksRepository {
override fun taskLists(): Flow<List<TaskList>> = observing { dataSource.taskLists() }
override fun tasks(filter: TaskFilter): Flow<List<Task>> = observing { loadTasks(filter) }
@OptIn(ExperimentalCoroutinesApi::class)
override fun filteredTasks(filter: TaskFilter): Flow<FilteredTasks> = when (filter) {
// A deleted filter shows nothing rather than failing the screen or widget on it.
is TaskFilter.Saved -> settingsPrefs.savedFilter(filter.id)
.flatMapLatest { saved ->
if (saved == null) flowOf(FilteredTasks(emptyList()))
else observing {
// A deleted list's id would otherwise leave the filter quietly empty.
val current = saved.withListsIn(dataSource.taskLists().mapTo(HashSet()) { it.id })
FilteredTasks(loadTasks(filter, current), current)
}
}
else -> observing { FilteredTasks(loadTasks(filter)) }
}
override fun subtasks(parentId: Long): Flow<List<Task>> = observing {
SeriesCollapse.visible(dataSource.subtasks(parentId))
@@ -55,7 +74,7 @@ class TasksRepositoryImpl @Inject constructor(
}
}
private fun loadTasks(filter: TaskFilter): List<Task> {
private fun loadTasks(filter: TaskFilter, saved: SavedFilter? = null): List<Task> {
val query = when (filter) {
is TaskFilter.OfList -> TaskQuery(listId = filter.listId, includeCompleted = true)
// Read completed tasks too, then let [TaskFiltering.matches] enforce each
@@ -66,6 +85,8 @@ class TasksRepositoryImpl @Inject constructor(
// Same over-read as Smart: TaskQuery.listId only narrows to one list, so
// several lists are read in full and membership is filtered in-memory.
is TaskFilter.OfLists -> TaskQuery(includeCompleted = true)
is TaskFilter.Tagged -> TaskQuery(includeCompleted = true)
is TaskFilter.Saved -> TaskQuery(includeCompleted = true)
}
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
val all = SeriesCollapse.visible(dataSource.tasks(query))
@@ -77,7 +98,7 @@ class TasksRepositoryImpl @Inject constructor(
(if (t.isCompleted) done + 1 else done) to (total + 1)
}
return all
.filter { TaskFiltering.matches(it, filter, todayStart, todayEnd) }
.filter { TaskFiltering.matches(it, filter, todayStart, todayEnd, saved) }
.map { task ->
progress[task.taskId]?.let { (done, total) ->
task.copy(subtaskDone = done, subtaskTotal = total)
@@ -112,6 +133,7 @@ class TasksRepositoryImpl @Inject constructor(
parentId = parentId,
location = task.location,
url = task.url,
tags = task.tags,
),
)
val reminders = dataSource.reminders()[task.taskId].orEmpty()
@@ -226,7 +248,7 @@ class TasksRepositoryImpl @Inject constructor(
val current = dataSource.task(taskId, occurrenceStart)
val seriesId = current?.seriesId
val occurrence = current?.occurrenceStart
if (seriesId == null || occurrence == null) return@withContext deleteWithSubtasks(taskId)
if (seriesId == null || occurrence == null) return@withContext dataSource.deleteWithSubtasks(taskId)
when (scope) {
RecurringScope.ThisOccurrence ->
// An override row deletes itself into an EXDATE; a generated
@@ -234,18 +256,10 @@ class TasksRepositoryImpl @Inject constructor(
if (current.taskId != seriesId) dataSource.deleteTask(current.taskId)
else dataSource.deleteOccurrence(seriesId, occurrence)
RecurringScope.ThisAndFollowing -> dataSource.deleteFollowing(seriesId, occurrence)
RecurringScope.AllOccurrences -> deleteWithSubtasks(seriesId)
RecurringScope.AllOccurrences -> dataSource.deleteWithSubtasks(seriesId)
}
}
/** A parent goes with its subtasks; left behind they would hang off a tombstone. */
private fun deleteWithSubtasks(taskId: Long) {
dataSource.subtasks(taskId).map { it.taskId }.distinct()
.filter { it != taskId }
.forEach { dataSource.deleteTask(it) }
dataSource.deleteTask(taskId)
}
override suspend fun createLocalList(name: String, color: Int): Long =
withContext(io) { dataSource.createLocalList(name, color) }
@@ -9,6 +9,7 @@ import de.jeanlucmakiola.agendula.domain.ical.ICalParser
import de.jeanlucmakiola.agendula.domain.ical.ICalProperty
import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
import de.jeanlucmakiola.agendula.domain.normalizeTags
import java.time.ZoneId
import kotlin.time.Clock
import kotlin.time.Instant
@@ -81,6 +82,8 @@ object VTodoMapper {
// Parameterised copies stay in the residue verbatim; authoring the
// column beside them would emit the same exclusions twice.
"RDATE", "EXDATE",
// Several lines, or one the column would not re-serialise exactly.
"CATEGORIES",
)
/** What a VTODO yields. Row identity ([TaskEntity.id], `listId`) is the caller's. */
@@ -200,6 +203,9 @@ object VTodoMapper {
CLASS_NAMES.indexOf(classProperty?.value?.trim()?.uppercase()).takeIf { it >= 0 },
)
val (categories, categoriesProperty) = readCategories(vtodo)
claim(categoriesProperty)
val sequenceProperty = vtodo.property("SEQUENCE")
val sequence = take(
sequenceProperty,
@@ -225,6 +231,7 @@ object VTodoMapper {
title = take(vtodo.property("SUMMARY"), vtodo.property("SUMMARY")?.text()),
description = take(vtodo.property("DESCRIPTION"), vtodo.property("DESCRIPTION")?.text()),
location = take(vtodo.property("LOCATION"), vtodo.property("LOCATION")?.text()),
categories = categories,
// URI, not TEXT — escaping it would corrupt a query string.
url = take(vtodo.property("URL"), vtodo.property("URL")?.value?.trim()),
status = status,
@@ -284,8 +291,9 @@ object VTodoMapper {
now: Instant = Clock.System.now(),
): ICalComponent {
val residue = parseResidue(entity.unknownProperties)
val residueCategories = normalizeTags(residue.properties("CATEGORIES").flatMap(::categoryValues))
val keptResidue =
residue.properties.filterNot { contradictsResidue(it, residue, entity, parentUid) }
residue.properties.filterNot { contradictsResidue(it, residue, residueCategories, entity, parentUid) }
val suppressed = keptResidue
.map { it.name.uppercase() }
.filterTo(mutableSetOf()) { it in SUPPRESSED_BY_RESIDUE }
@@ -307,6 +315,7 @@ object VTodoMapper {
add("DESCRIPTION", entity.description?.let(ICalValues::escapeText))
add("LOCATION", entity.location?.let(ICalValues::escapeText))
add("URL", entity.url)
add("CATEGORIES", normalizeTags(entity.categories).takeIf { it.isNotEmpty() }?.let(ICalValues::formatTextList))
add("STATUS", entity.status.toICalName())
add("PERCENT-COMPLETE", entity.percentComplete?.toString())
@@ -386,6 +395,7 @@ object VTodoMapper {
private fun contradictsResidue(
property: ICalProperty,
residue: ICalComponent,
residueCategories: List<String>,
entity: TaskEntity,
parentUid: String?,
): Boolean {
@@ -433,6 +443,7 @@ object VTodoMapper {
"PRIORITY" -> entity.priority != PRIORITY_NONE
"PERCENT-COMPLETE" -> entity.percentComplete != null
"CLASS" -> entity.classification != null
"CATEGORIES" -> entity.categories != residueCategories
// Not SEQUENCE: it is the organiser's counter and never ours to bump,
// so the column stays at its fallback and the residue always wins.
else -> false
@@ -475,6 +486,23 @@ object VTodoMapper {
// -------------------------------------------------------------- helpers
/**
* Every `CATEGORIES` copy's values, merged, and the one line to claim — only
* when it is the sole, parameterless copy and the column re-serialises it
* byte for byte. Anything else is read for display and stays in the residue.
*/
private fun readCategories(vtodo: ICalComponent): Pair<List<String>, ICalProperty?> {
val copies = vtodo.properties("CATEGORIES")
val values = normalizeTags(copies.flatMap(::categoryValues))
val claimable = copies.singleOrNull()?.takeIf {
it.params.isEmpty() && values.isNotEmpty() && ICalValues.formatTextList(values) == it.value
}
return values to claimable
}
private fun categoryValues(property: ICalProperty): List<String> =
normalizeTags(ICalValues.parseTextList(property.value))
private val CLASS_NAMES = listOf("PUBLIC", "PRIVATE", "CONFIDENTIAL")
private class TimeRead(
@@ -15,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.TasksDatabase
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
import de.jeanlucmakiola.agendula.domain.normalizeTags
import de.jeanlucmakiola.agendula.domain.statusFromInt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
@@ -221,12 +222,17 @@ class OneShotImport @Inject constructor(
}
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 ->
val r = CursorColumnReader(cursor)
while (cursor.moveToNext()) {
if (r.getString("mimetype") != ALARM_MIMETYPE) 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
alarms += LegacyAlarm(
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 ----------------------------------------------------
@@ -276,7 +282,12 @@ class OneShotImport @Inject constructor(
// the unique index; a fresh uid costs the row nothing it still has.
val uid = row.entity.uid.takeIf { seen.add(Triple(listId, it, recurrenceId)) }
?: UUID.randomUUID().toString()
val entity = row.entity.copy(listId = listId, uid = uid, recurrenceId = recurrenceId)
val entity = row.entity.copy(
listId = listId,
uid = uid,
recurrenceId = recurrenceId,
categories = snapshot.categories[row.id].orEmpty(),
)
val newId = taskDao.insert(entity)
taskIds[row.id] = newId
inserted += row to entity.copy(id = newId)
@@ -379,6 +390,7 @@ class OneShotImport @Inject constructor(
const val ARCHIVE_NAME = "tasks.db.imported"
private const val ALARM_MIMETYPE = "vnd.android.cursor.item/alarm"
private const val CATEGORY_MIMETYPE = "vnd.android.cursor.item/category"
private const val REFERENCE_START = "2"
private val SIDECARS = listOf("-journal", "-wal", "-shm")
private val IMPORT_DONE = booleanPreferencesKey("legacy_import_done")
@@ -391,6 +403,8 @@ private class LegacySnapshot(
val lists: List<LegacyList>,
val tasks: List<LegacyTaskRow>,
val alarms: List<LegacyAlarm>,
/** Tags by legacy task `_id`. */
val categories: Map<Long, List<String>>,
)
private class LegacyList(val id: Long, val entity: TaskListEntity)
@@ -0,0 +1,99 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
import android.content.ContentProvider
import android.content.ContentProviderOperation
import android.content.ContentProviderResult
import android.content.ContentUris
import android.content.ContentValues
import android.content.UriMatcher
import android.database.Cursor
import android.net.Uri
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
/**
* Agendula's own store, published under the OpenTasks contract (`tasklists`,
* `tasks`, `instances`, `properties`) at `<applicationId>.tasks`, so an app that
* already works with OpenTasks or tasks.org works with Agendula by swapping the
* authority. Reads are guarded by `<applicationId>.permission.READ_TASKS`, writes
* by `<applicationId>.permission.WRITE_TASKS`; [ProviderWriter] applies them. See
* `docs/CONTENT_PROVIDER.md`.
*/
class AgendulaTaskProvider : ContentProvider() {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface ProviderEntryPoint {
fun snapshotSource(): ProviderSnapshotSource
fun writer(): ProviderWriter
}
private val entryPoint by lazy {
EntryPointAccessors.fromApplication(requireNotNull(context), ProviderEntryPoint::class.java)
}
private val source: ProviderSnapshotSource by lazy { entryPoint.snapshotSource() }
private val writer: ProviderWriter by lazy { entryPoint.writer() }
private val matcher by lazy {
UriMatcher(UriMatcher.NO_MATCH).apply {
ProviderTable.entries.forEach { table ->
addURI(source.authority, table.path, table.ordinal * 2)
addURI(source.authority, "${table.path}/#", table.ordinal * 2 + 1)
}
}
}
override fun onCreate(): Boolean = true
override fun query(
uri: Uri,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?,
): Cursor {
val (table, id) = match(uri)
return source.query(table, projection, table.selectionFor(id, selection), selectionArgs, sortOrder).apply {
setNotificationUri(requireNotNull(context).contentResolver, uri)
}
}
override fun getType(uri: Uri): String {
val (table, id) = match(uri)
return table.mimeType(single = id != null)
}
override fun insert(uri: Uri, values: ContentValues?): Uri {
val (table, id) = match(uri)
require(id == null) { "Insert into ${table.path}, not into one of its rows" }
return ContentUris.withAppendedId(uri, writer.insert(table, values.toMap()))
}
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int {
val (table, id) = match(uri)
return writer.update(table, values.toMap(), table.selectionFor(id, selection), selectionArgs)
}
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
val (table, id) = match(uri)
return writer.delete(table, table.selectionFor(id, selection), selectionArgs)
}
/** All or nothing, as against OpenTasks, and observers hear of it once. */
override fun applyBatch(operations: ArrayList<ContentProviderOperation>): Array<ContentProviderResult> =
writer.batch { super.applyBatch(operations) }
private fun match(uri: Uri): Pair<ProviderTable, Long?> {
val code = matcher.match(uri)
require(code != UriMatcher.NO_MATCH) { "Unknown URI $uri" }
val table = ProviderTable.entries[code / 2]
if (code % 2 == 0) return table to null
val id = requireNotNull(uri.lastPathSegment?.toLongOrNull()) { "Unknown URI $uri" }
return table to id
}
private fun ContentValues?.toMap(): ProviderValues =
this?.keySet()?.associateWith { get(it) }.orEmpty()
}
@@ -0,0 +1,116 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
/** One table [AgendulaTaskProvider] serves, with its columns in cursor order. */
enum class ProviderTable(val path: String, val columns: List<Pair<String, String>>) {
LISTS(
Lists.PATH,
listOf(
Lists.ID to "INTEGER PRIMARY KEY",
Lists.NAME to "TEXT",
Lists.COLOR to "INTEGER",
Lists.ACCESS_LEVEL to "INTEGER",
Lists.VISIBLE to "INTEGER",
Lists.SYNC_ENABLED to "INTEGER",
Lists.OWNER to "TEXT",
Lists.ACCOUNT_NAME to "TEXT",
Lists.ACCOUNT_TYPE to "TEXT",
),
),
TASKS(Tasks.PATH, listOf(Tasks.ID to "INTEGER PRIMARY KEY") + TASK_COLUMNS),
INSTANCES(
Instances.PATH,
listOf(
Tasks.ID to "INTEGER PRIMARY KEY",
Instances.TASK_ID to "INTEGER",
Instances.INSTANCE_START to "INTEGER",
Instances.INSTANCE_START_SORTING to "INTEGER",
Instances.INSTANCE_DUE to "INTEGER",
Instances.INSTANCE_DUE_SORTING to "INTEGER",
Instances.INSTANCE_DURATION to "INTEGER",
Instances.INSTANCE_ORIGINAL_TIME to "INTEGER",
Instances.DISTANCE_FROM_CURRENT to "INTEGER",
Instances.IS_RECURRING to "INTEGER",
) + TASK_COLUMNS,
),
PROPERTIES(
Properties.PATH,
listOf(
Properties.PROPERTY_ID to "INTEGER PRIMARY KEY",
Properties.TASK_ID to "INTEGER",
Properties.MIMETYPE to "TEXT",
Properties.DATA0 to "TEXT",
Properties.DATA1 to "TEXT",
Properties.DATA2 to "TEXT",
Properties.DATA3 to "TEXT",
),
),
;
val idColumn: String get() = columns.first().first
/** [selection] narrowed to the row [id] names, for a `/<id>` URI. */
fun selectionFor(id: Long?, selection: String?): String? =
if (id == null) selection else listOfNotNull("$idColumn = $id", selection?.let { "($it)" }).joinToString(" AND ")
/** OpenTasks' MIME type for a whole table, or for one row of it. */
fun mimeType(single: Boolean): String =
"${if (single) "vnd.android.cursor.item" else "vnd.android.cursor.dir"}/org.dmfs.tasks.$path"
}
/** Every `tasks` column but `_id`; the instances view carries them too. */
private val TASK_COLUMNS: List<Pair<String, String>>
get() = listOf(
Tasks.LIST_ID to "INTEGER",
Tasks.LIST_NAME to "TEXT",
Tasks.LIST_COLOR to "INTEGER",
Tasks.LIST_OWNER to "TEXT",
Tasks.LIST_ACCESS_LEVEL to "INTEGER",
Tasks.VISIBLE to "INTEGER",
Tasks.ACCOUNT_NAME to "TEXT",
Tasks.ACCOUNT_TYPE to "TEXT",
Tasks.UID to "TEXT",
Tasks.DELETED to "INTEGER",
Tasks.TITLE to "TEXT",
Tasks.DESCRIPTION to "TEXT",
Tasks.LOCATION to "TEXT",
Tasks.URL to "TEXT",
Tasks.PRIORITY to "INTEGER",
Tasks.CLASSIFICATION to "INTEGER",
Tasks.STATUS to "INTEGER",
Tasks.PERCENT_COMPLETE to "INTEGER",
Tasks.COMPLETED to "INTEGER",
Tasks.IS_CLOSED to "INTEGER",
Tasks.IS_NEW to "INTEGER",
Tasks.TASK_COLOR to "INTEGER",
Tasks.DTSTART to "INTEGER",
Tasks.DUE to "INTEGER",
Tasks.DURATION to "TEXT",
Tasks.IS_ALLDAY to "INTEGER",
Tasks.TZ to "TEXT",
Tasks.RRULE to "TEXT",
Tasks.RDATE to "TEXT",
Tasks.EXDATE to "TEXT",
Tasks.ORIGINAL_INSTANCE_ID to "INTEGER",
Tasks.ORIGINAL_INSTANCE_TIME to "INTEGER",
Tasks.ORIGINAL_INSTANCE_ALLDAY to "INTEGER",
Tasks.PARENT_ID to "INTEGER",
Tasks.SORTING to "INTEGER",
Tasks.HAS_ALARMS to "INTEGER",
Tasks.CREATED to "INTEGER",
Tasks.LAST_MODIFIED to "INTEGER",
) + UNSUPPORTED_TASK_COLUMNS.map { it to "TEXT" }
/**
* OpenTasks columns Agendula has nothing for. Published as `NULL` so a client
* that projects them reads nothing rather than failing the query.
*/
private val UNSUPPORTED_TASK_COLUMNS = listOf(
"geo", "organizer", "pinned", "has_properties", "completed_is_allday", "original_instance_sync_id",
"version", "_dirty", "_sync_id", "sync_version",
"sync1", "sync2", "sync3", "sync4", "sync5", "sync6", "sync7", "sync8",
)
@@ -0,0 +1,267 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
import de.jeanlucmakiola.agendula.data.tasks.TasksContract
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Alarm
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Category
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListRow
import de.jeanlucmakiola.agendula.data.tasks.room.seriesOccurrences
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.toInt
import java.time.ZoneId
import kotlin.time.Instant
typealias ProviderRow = Map<String, Any?>
/**
* Ids that stay put for as long as what they name exists, so a client can read
* an id and write to it later, or several in one batch. The one exception is
* two tags on one task whose spellings hash alike (about one in 2³¹): removing
* the one that sorts first moves the other onto its id.
*
* An instance is its series and its original time; a plain task's one
* instance has none. An alarm property is the alarm row; a tag property is its
* task and its spelling. Alarms take even ids and tags odd ones, so the two
* never meet.
*/
object ProviderIds {
fun instance(seriesId: Long, originalTime: Instant?): Long =
(seriesId shl 32) or ((originalTime?.epochSeconds ?: 0L) and 0xFFFFFFFFL)
fun instanceSeries(instanceId: Long): Long = instanceId ushr 32
fun instanceSeconds(instanceId: Long): Long = instanceId and 0xFFFFFFFFL
fun alarm(alarmId: Long): Long = alarmId shl 1
fun isAlarm(propertyId: Long): Boolean = propertyId and 1L == 0L
fun alarmRow(propertyId: Long): Long = propertyId ushr 1
/** The property id of each of [taskId]'s [categories], by spelling. */
fun categories(taskId: Long, categories: List<String>): Map<String, Long> =
categories(taskId, categories, ::tagHash)
internal fun categories(taskId: Long, categories: List<String>, hash: (String) -> Long): Map<String, Long> {
val used = HashSet<Long>()
// Two spellings on one task with one hash: the one that sorts later takes
// the next free slot, wrapping inside the tag bits so it never reaches the
// task's. Sorting keeps that independent of the tags' order.
val ids = categories.sorted().associateWith { name ->
var bits = hash(name) and TAG_BITS
while (!used.add(bits)) bits = (bits + 1) and TAG_BITS
(((taskId shl 31) or bits) shl 1) or 1L
}
return categories.associateWith(ids::getValue)
}
fun categoryTask(propertyId: Long): Long = propertyId ushr 32
private const val TAG_BITS = 0x7FFFFFFFL
/** 64-bit FNV-1a over the spelling's UTF-8, folded to 31 bits. */
private fun tagHash(name: String): Long {
var hash = -0x340d631b7bdddcdbL
name.encodeToByteArray().forEach { byte ->
hash = (hash xor (byte.toLong() and 0xFF)) * 0x100000001b3L
}
return (hash xor (hash ushr 31) xor (hash ushr 62)) and TAG_BITS
}
}
/** The Room store laid out as OpenTasks' tables, ready to be queried. */
class ProviderSnapshot(val rows: Map<ProviderTable, List<ProviderRow>>) {
companion object {
val EMPTY = ProviderSnapshot(ProviderTable.entries.associateWith { emptyList() })
/**
* Lays out the store. [tasks] must hold no tombstones. [accountType] is
* what a list attached to an account reports; a device-only list reports
* OpenTasks' local account so clients treat it the way they already do.
*/
fun build(
lists: List<TaskListRow>,
tasks: List<TaskEntity>,
alarms: List<TaskAlarmEntity>,
accountType: String,
now: Instant,
zone: ZoneId = ZoneId.systemDefault(),
): ProviderSnapshot {
val listsById = lists.associateBy { it.list.id }
val alarmsByTask = alarms.groupBy { it.taskId }
val taskRows = tasks.mapNotNull { task ->
val list = listsById[task.listId] ?: return@mapNotNull null
task.id to taskColumns(task, list, alarmsByTask[task.id].orEmpty().isNotEmpty(), accountType)
}.toMap()
val overrides = tasks.filter { it.masterId != null }.groupBy { it.masterId }
val instances = tasks.filter { it.masterId == null && it.id in taskRows }.flatMap { task ->
occurrences(task, overrides[task.id].orEmpty(), now).mapNotNull { occurrence ->
val columns = taskRows[occurrence.row.id] ?: return@mapNotNull null
columns + instanceColumns(ProviderIds.instance(task.id, occurrence.originalTime), occurrence, zone)
}
}
return ProviderSnapshot(
mapOf(
ProviderTable.LISTS to lists.map { listColumns(it, accountType) },
ProviderTable.TASKS to taskRows.map { (id, columns) -> columns + (Tasks.ID to id) },
ProviderTable.INSTANCES to instances,
ProviderTable.PROPERTIES to properties(tasks.filter { it.id in taskRows }, alarms),
),
)
}
private fun listColumns(row: TaskListRow, accountType: String): ProviderRow = mapOf(
Lists.ID to row.list.id,
Lists.NAME to row.list.name,
Lists.COLOR to row.list.color,
Lists.ACCESS_LEVEL to 0,
Lists.VISIBLE to row.list.isVisible.bit,
Lists.SYNC_ENABLED to row.list.isSynced.bit,
Lists.OWNER to row.list.owner,
Lists.ACCOUNT_NAME to accountName(row),
Lists.ACCOUNT_TYPE to accountTypeOf(row, accountType),
)
private fun taskColumns(task: TaskEntity, list: TaskListRow, hasAlarms: Boolean, accountType: String) =
mapOf(
Tasks.LIST_ID to task.listId,
Tasks.LIST_NAME to list.list.name,
Tasks.LIST_COLOR to list.list.color,
Tasks.LIST_OWNER to list.list.owner,
Tasks.LIST_ACCESS_LEVEL to 0,
Tasks.VISIBLE to list.list.isVisible.bit,
Tasks.ACCOUNT_NAME to accountName(list),
Tasks.ACCOUNT_TYPE to accountTypeOf(list, accountType),
Tasks.UID to task.uid,
Tasks.DELETED to 0,
Tasks.TITLE to task.title,
Tasks.DESCRIPTION to task.description,
Tasks.LOCATION to task.location,
Tasks.URL to task.url,
Tasks.PRIORITY to task.priority,
Tasks.CLASSIFICATION to task.classification,
Tasks.STATUS to task.status.toInt(),
Tasks.PERCENT_COMPLETE to task.percentComplete,
Tasks.COMPLETED to task.completedAt?.toEpochMilliseconds(),
Tasks.IS_CLOSED to (task.status == TaskStatus.COMPLETED || task.status == TaskStatus.CANCELLED).bit,
Tasks.IS_NEW to (task.status == TaskStatus.NEEDS_ACTION && task.percentComplete == null).bit,
Tasks.TASK_COLOR to task.color,
Tasks.DTSTART to task.dtstart?.toEpochMilliseconds(),
Tasks.DUE to task.due?.toEpochMilliseconds(),
Tasks.DURATION to task.duration,
Tasks.IS_ALLDAY to task.isAllDay.bit,
Tasks.TZ to task.timezone,
Tasks.RRULE to task.rrule,
Tasks.RDATE to task.rdate,
Tasks.EXDATE to task.exdate,
Tasks.ORIGINAL_INSTANCE_ID to task.masterId,
Tasks.ORIGINAL_INSTANCE_TIME to task.recurrenceId?.toEpochMilliseconds(),
Tasks.ORIGINAL_INSTANCE_ALLDAY to task.masterId?.let { task.isAllDay.bit },
Tasks.PARENT_ID to task.parentId,
Tasks.SORTING to task.sortOrder,
Tasks.HAS_ALARMS to hasAlarms.bit,
Tasks.CREATED to task.createdAt?.toEpochMilliseconds(),
Tasks.LAST_MODIFIED to task.lastModified?.toEpochMilliseconds(),
)
/** One instance: the row it reads from, its times, and where it sits in its series. */
private class Occurrence(
val row: TaskEntity,
val start: Instant?,
val due: Instant?,
val originalTime: Instant?,
val distance: Int,
val recurring: Boolean,
)
private fun occurrences(master: TaskEntity, overrides: List<TaskEntity>, now: Instant): List<Occurrence> {
val series = seriesOccurrences(master, overrides, now)
?: return listOf(Occurrence(master, master.dtstart, master.due, null, 0, recurring = false))
return series.mapIndexed { index, occurrence ->
Occurrence(
row = occurrence.override ?: master,
start = occurrence.start,
due = occurrence.due,
originalTime = occurrence.anchor,
distance = occurrence.distance ?: index,
recurring = true,
)
}
}
private fun instanceColumns(id: Long, occurrence: Occurrence, zone: ZoneId): ProviderRow {
val allDay = occurrence.row.isAllDay
val start = occurrence.start?.toEpochMilliseconds()
val due = occurrence.due?.toEpochMilliseconds()
return mapOf(
Tasks.ID to id,
Instances.TASK_ID to occurrence.row.id,
Instances.INSTANCE_START to start,
Instances.INSTANCE_START_SORTING to start?.let { sorting(it, allDay, zone) },
Instances.INSTANCE_DUE to due,
Instances.INSTANCE_DUE_SORTING to due?.let { sorting(it, allDay, zone) },
Instances.INSTANCE_DURATION to if (start != null && due != null) due - start else null,
Instances.INSTANCE_ORIGINAL_TIME to occurrence.originalTime?.toEpochMilliseconds(),
Instances.DISTANCE_FROM_CURRENT to occurrence.distance,
Instances.IS_RECURRING to occurrence.recurring.bit,
)
}
/**
* An all-day date is stored as UTC midnight; OpenTasks' sorting columns
* move it to local midnight so it sorts among the timed tasks of that day.
*/
private fun sorting(millis: Long, allDay: Boolean, zone: ZoneId): Long {
if (!allDay) return millis
val offset = zone.rules.getOffset(java.time.Instant.ofEpochMilli(millis)).totalSeconds * 1000L
return millis - offset
}
private fun properties(tasks: List<TaskEntity>, alarms: List<TaskAlarmEntity>): List<ProviderRow> {
val live = tasks.mapTo(HashSet()) { it.id }
val alarmRows = alarms.filter { it.taskId in live }.map {
mapOf(
Properties.PROPERTY_ID to ProviderIds.alarm(it.id),
Properties.TASK_ID to it.taskId,
Properties.MIMETYPE to Alarm.MIMETYPE,
Alarm.MINUTES_BEFORE to it.minutesBefore.toString(),
Alarm.REFERENCE to when (it.reference) {
AlarmReference.DUE -> Alarm.REFERENCE_DUE
AlarmReference.START -> Alarm.REFERENCE_START
}.toString(),
Alarm.MESSAGE to it.message,
Alarm.ALARM_TYPE to Alarm.TYPE_MESSAGE.toString(),
)
}
val categoryRows = tasks.flatMap { task ->
ProviderIds.categories(task.id, task.categories).map { (name, id) ->
mapOf(
Properties.PROPERTY_ID to id,
Properties.TASK_ID to task.id,
Properties.MIMETYPE to Category.MIMETYPE,
Category.NAME to name,
)
}
}
return alarmRows + categoryRows
}
private fun accountName(row: TaskListRow): String =
row.accountDisplayName ?: TasksContract.LOCAL_ACCOUNT_NAME
private fun accountTypeOf(row: TaskListRow, accountType: String): String =
if (row.list.accountId == null) TasksContract.LOCAL_ACCOUNT_TYPE else accountType
private val Boolean.bit: Int get() = if (this) 1 else 0
}
}
@@ -0,0 +1,182 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
import android.content.Context
import android.database.Cursor
import android.os.Handler
import android.os.Looper
import androidx.room.InvalidationTracker
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StartupGate
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import de.jeanlucmakiola.agendula.data.tasks.TasksContract
import de.jeanlucmakiola.agendula.data.tasks.room.OBSERVED_TABLES
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListRow
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Clock
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
/** Rebuilt at least this often, so the expansion window and `distance_from_current` follow the clock. */
private val MAX_AGE = 1.hours
private val STARTUP_WAIT = 10.seconds
private val NOTIFY_DELAY = 500.milliseconds
/**
* Serves [AgendulaTaskProvider]: keeps a [SnapshotDatabase] of the Room store,
* rebuilt lazily after a write or a storage-mode switch, and tells observers of
* the provider's authority when either happens.
*
* Empty in [StorageMode.EXTERNAL]: the Room store is not the user's data then,
* and clients should read OpenTasks or tasks.org directly.
*/
@Singleton
class ProviderSnapshotSource internal constructor(
private val context: Context,
private val database: TasksDatabase,
private val resolver: ProviderResolver,
private val awaitStartup: suspend () -> Unit,
private val clock: Clock = Clock.System,
private val startupWait: Duration = STARTUP_WAIT,
) {
@Inject constructor(
@ApplicationContext context: Context,
database: TasksDatabase,
resolver: ProviderResolver,
startupGate: StartupGate,
) : this(context, database, resolver, startupGate::awaitReady)
private val started = AtomicBoolean(false)
private val snapshot by lazy { SnapshotDatabase() }
private val generation = AtomicLong(1)
private var builtGeneration = 0L
private var builtAt: Instant? = null
private val handler = Handler(Looper.getMainLooper())
private val notify = Runnable {
context.contentResolver.notifyChange(TasksContract.authorityUri(authority), null)
}
val authority: String get() = "${context.packageName}.$AUTHORITY_SUFFIX"
/** Starts watching for changes. Idempotent; touches the database, so not from the main thread. */
fun start() {
if (!started.compareAndSet(false, true)) return
database.invalidationTracker.addObserver(
object : InvalidationTracker.Observer(OBSERVED_TABLES) {
override fun onInvalidated(tables: Set<String>) {
if (resolver.mode() == StorageMode.OWN) changed()
}
},
)
resolver.onModeChanged(::changed)
}
@Synchronized
fun query(
table: ProviderTable,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?,
): Cursor {
refresh()
return snapshot.query(table, projection, selection, selectionArgs, sortOrder)
}
/**
* Runs [block] holding the snapshot's lock, once startup has settled which
* store is the user's. A write refuses to run without that, where a read
* falls back to answering from whichever store autoMode() picks.
*/
fun <T> locked(block: () -> T): T {
start()
checkNotNull(runBlocking { withTimeoutOrNull(startupWait) { awaitStartup() } }) {
"Agendula is still starting; try again"
}
return synchronized(this, block)
}
/** The [columns] of every row of [table] that [selection] matches, as of now. */
@Synchronized
fun rows(table: ProviderTable, columns: List<String>, selection: String?, selectionArgs: Array<out String>?): List<List<Any?>> =
query(table, columns.toTypedArray(), selection, selectionArgs, null).use { cursor ->
buildList {
while (cursor.moveToNext()) {
add(
List(cursor.columnCount) { column ->
when (cursor.getType(column)) {
Cursor.FIELD_TYPE_NULL -> null
Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(column)
else -> cursor.getString(column)
}
},
)
}
}
}
/**
* Marks the snapshot stale at once. A provider write calls this so the next
* operation of the same batch sees it, before Room's own invalidation lands.
*/
fun invalidate() {
generation.incrementAndGet()
}
@Synchronized
private fun refresh() {
start()
runBlocking { withTimeoutOrNull(startupWait) { awaitStartup() } }
val now = clock.now()
val current = generation.get()
val expired = builtAt.let { it == null || now - it > MAX_AGE }
if (current != builtGeneration || expired) {
snapshot.load(build(now))
builtGeneration = current
// Instance ids and distances move with the clock, so a timed rebuild is a change too.
if (expired && builtAt != null) notifyObservers()
builtAt = now
}
}
private fun build(now: Instant): ProviderSnapshot {
if (resolver.mode() != StorageMode.OWN) return ProviderSnapshot.EMPTY
// Read consistently in one transaction; expand outside it, so the app's
// own writes do not wait on recurrence expansion.
val (lists, tasks, alarms) = database.runInTransaction<Triple<List<TaskListRow>, List<TaskEntity>, List<TaskAlarmEntity>>> {
Triple(database.taskLists().lists(), database.tasks().live(), database.alarms().all())
}
return ProviderSnapshot.build(lists, tasks, alarms, context.getString(R.string.account_type), now)
}
private fun changed() {
generation.incrementAndGet()
notifyObservers()
}
/** Coalesced, so a sync committing many small transactions wakes each client once. */
private fun notifyObservers() {
handler.removeCallbacks(notify)
handler.postDelayed(notify, NOTIFY_DELAY.inWholeMilliseconds)
}
companion object {
const val AUTHORITY_SUFFIX = "tasks"
}
}
@@ -0,0 +1,304 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
import de.jeanlucmakiola.agendula.data.tasks.TasksContract
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.data.tasks.room.forAllDay
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.statusFromInt
import org.dmfs.rfc5545.recur.RecurrenceRule
import java.time.ZoneId
import kotlin.time.Instant
/** A client's `ContentValues`, as plain Kotlin values. */
typealias ProviderValues = Map<String, Any?>
/** The `tasks` columns a client may write. Everything else the table publishes is ignored on a write. */
val WRITABLE_TASK_COLUMNS: Set<String> = setOf(
Tasks.LIST_ID, Tasks.UID, Tasks.TITLE, Tasks.DESCRIPTION, Tasks.LOCATION, Tasks.URL, Tasks.PRIORITY,
Tasks.CLASSIFICATION, Tasks.STATUS, Tasks.PERCENT_COMPLETE, Tasks.COMPLETED, Tasks.TASK_COLOR, Tasks.DTSTART,
Tasks.DUE, Tasks.DURATION, Tasks.IS_ALLDAY, Tasks.TZ, Tasks.RRULE, Tasks.RDATE, Tasks.EXDATE, Tasks.PARENT_ID,
Tasks.SORTING,
)
/** The columns that say whether a task, or one occurrence of it, is done. */
val COMPLETION_COLUMNS: Set<String> = setOf(Tasks.STATUS, Tasks.PERCENT_COMPLETE, Tasks.COMPLETED)
/** The columns that place a task in time; one occurrence of a series cannot be moved through the provider. */
val TIME_COLUMNS: Set<String> = setOf(
Tasks.DTSTART, Tasks.DUE, Tasks.DURATION, Tasks.IS_ALLDAY, Tasks.TZ, Tasks.RRULE, Tasks.RDATE, Tasks.EXDATE,
)
/** What a write to one occurrence of a series does to it. */
enum class OccurrenceChange { COMPLETE, CANCEL, REOPEN }
private val ICAL_DATE = Regex("""\d{8}(T\d{6}Z?)?""")
private val ICAL_DURATION = Regex("""[+-]?P(\d+W|(\d+D)?(T(\d+H)?(\d+M)?(\d+S)?)?)""")
/**
* Turns a client's OpenTasks `ContentValues` into [TaskEntity] changes. Pure, so
* the rules are testable on the JVM. A bad value throws [IllegalArgumentException],
* which reaches the client as is.
*
* Only [WRITABLE_TASK_COLUMNS] change anything. The rest of what the provider
* publishes is accepted and ignored, so a client can write back a row it read;
* the fields Agendula keeps for CalDAV (`href`, `etag`, `sequence`, unknown
* properties) are not published and so cannot be written at all.
*/
object ProviderTaskWriter {
/**
* [values] narrowed to the columns in [writable]. A column [table] does not
* have at all is a client bug and throws.
*/
fun writable(table: ProviderTable, values: ProviderValues, writable: Set<String>): ProviderValues {
val known = table.columns.mapTo(HashSet()) { it.first }
val unknown = values.keys - known
require(unknown.isEmpty()) { "Unknown column(s) for ${table.path}: ${unknown.sorted().joinToString()}" }
return values.filterKeys { it in writable }
}
/** A new task from [values], which must name its list. */
fun newTask(values: ProviderValues, uid: String, now: Instant, tzId: String): TaskEntity {
val v = Values(values)
val listId = requireNotNull(v.long(Tasks.LIST_ID)) { "A new task needs a ${Tasks.LIST_ID}" }
val base = TaskEntity(listId = listId, uid = v.string(Tasks.UID)?.trim()?.ifEmpty { null } ?: uid, createdAt = now)
// As in OpenTasks, a new task inserted without a zone is floating.
val zoned = if (Tasks.TZ in v) values else values + (Tasks.TZ to null)
return applied(base, zoned - Tasks.LIST_ID - Tasks.UID, now, tzId)
}
/**
* [current] with [values] applied. Identity and sync columns are left alone,
* and [current] itself comes back when nothing changed, so a write that
* changes nothing owes the server nothing.
*/
fun patch(current: TaskEntity, values: ProviderValues, now: Instant, tzId: String): TaskEntity {
val edited = applied(current, values, now, tzId)
return if (edited.copy(lastModified = current.lastModified, isDirty = current.isDirty) == current) current else edited
}
/**
* The columns in [values] that would change [current] on their own. Writing
* back what a row published changes nothing, so a client that read a row can
* send all of it with the one value it meant to change.
*/
fun changedColumns(current: TaskEntity, values: ProviderValues, tzId: String): Set<String> {
val stamp = current.lastModified ?: Instant.DISTANT_PAST
return values.keys.filterTo(HashSet()) { key ->
val edited = runCatching { applied(current, mapOf(key to values[key]), stamp, tzId) }.getOrNull()
edited == null || edited.copy(lastModified = current.lastModified, isDirty = current.isDirty) != current
}
}
private fun applied(current: TaskEntity, values: ProviderValues, now: Instant, tzId: String): TaskEntity {
val v = Values(values)
val isOverride = current.masterId != null
if (Tasks.UID in v) require(v.string(Tasks.UID)?.trim() == current.uid) { "A task's ${Tasks.UID} cannot change" }
if (isOverride) {
// NULL is what the row publishes, so writing it back is fine.
require(listOf(Tasks.RRULE, Tasks.RDATE, Tasks.EXDATE).none { it in v && v.text(it) != null }) {
"A modified occurrence carries no recurrence rule of its own"
}
if (Tasks.LIST_ID in v) require(v.long(Tasks.LIST_ID) == current.listId) { "A modified occurrence stays in its series' list" }
if (Tasks.PARENT_ID in v) require(v.long(Tasks.PARENT_ID) == current.parentId) { "A modified occurrence keeps its series' parent" }
}
val listId = if (Tasks.LIST_ID in v) requireNotNull(v.long(Tasks.LIST_ID)) { "${Tasks.LIST_ID} cannot be null" } else current.listId
val parentId = if (Tasks.PARENT_ID in v) v.long(Tasks.PARENT_ID)?.takeIf { it > 0 } else current.parentId
require(parentId == null || parentId != current.id || current.id == 0L) { "A task cannot be its own parent" }
val allDay = if (Tasks.IS_ALLDAY in v) v.boolean(Tasks.IS_ALLDAY) else current.isAllDay
val dtstart = (if (Tasks.DTSTART in v) v.instant(Tasks.DTSTART) else current.dtstart)?.forAllDay(allDay)
val due = (if (Tasks.DUE in v) v.instant(Tasks.DUE) else current.due)?.forAllDay(allDay)
val duration = when {
Tasks.DURATION in v -> v.string(Tasks.DURATION)?.trim()?.ifEmpty { null }
?.also { require(ICAL_DURATION.matches(it)) { "Not an iCalendar duration: $it" } }
Tasks.DUE in v && due != null -> null
else -> current.duration
}
require(due == null || duration == null) { "${Tasks.DUE} and ${Tasks.DURATION} are mutually exclusive" }
require(dtstart == null || due == null || due >= dtstart) { "${Tasks.DUE} is before ${Tasks.DTSTART}" }
val timezone = when {
allDay -> null
Tasks.TZ in v -> v.string(Tasks.TZ)?.also { zone ->
require(runCatching { ZoneId.of(zone) }.isSuccess) { "Unknown time zone: $zone" }
}
dtstart == null && due == null -> null
// A timed task without a zone is floating (RFC 5545 §3.3.5) and stays so.
current.dtstart != null || current.due != null -> if (current.isAllDay) tzId else current.timezone
else -> current.timezone ?: tzId
}
val rrule = if (Tasks.RRULE in v) normalizedRule(v.string(Tasks.RRULE)) else current.rrule
val ruleDropped = Tasks.RRULE in v && rrule == null && current.rrule != null
val rdate = if (Tasks.RDATE in v) dateList(Tasks.RDATE, v, current.rdate) else current.rdate.takeUnless { ruleDropped }
val exdate = if (Tasks.EXDATE in v) dateList(Tasks.EXDATE, v, current.exdate) else current.exdate.takeUnless { ruleDropped }
require(rrule == null || dtstart != null || due != null) { "A recurring task needs a ${Tasks.DTSTART} or ${Tasks.DUE}" }
val (status, percent, completedAt) = completion(current, v, now)
return current.copy(
listId = listId,
title = if (Tasks.TITLE in v) v.string(Tasks.TITLE)?.trim() else current.title,
description = if (Tasks.DESCRIPTION in v) v.text(Tasks.DESCRIPTION) else current.description,
location = if (Tasks.LOCATION in v) v.text(Tasks.LOCATION) else current.location,
url = if (Tasks.URL in v) v.text(Tasks.URL) else current.url,
priority = if (Tasks.PRIORITY in v) v.int(Tasks.PRIORITY, 0..9) ?: TasksContract.PRIORITY_NONE else current.priority,
classification = if (Tasks.CLASSIFICATION in v) v.int(Tasks.CLASSIFICATION, 0..2) else current.classification,
color = if (Tasks.TASK_COLOR in v) v.int(Tasks.TASK_COLOR) else current.color,
sortOrder = if (Tasks.SORTING in v) v.int(Tasks.SORTING) ?: 0 else current.sortOrder,
status = status,
percentComplete = percent,
completedAt = completedAt,
dtstart = dtstart,
due = due,
duration = duration,
isAllDay = allDay,
timezone = timezone,
rrule = rrule,
rdate = rdate,
exdate = exdate,
parentId = parentId,
lastModified = now,
isDirty = true,
)
}
/**
* What [values] do to one occurrence of a series, or `null` when they say
* nothing about its completion. An occurrence is done, cancelled or open;
* partial progress belongs to a task, not to one of its occurrences.
*/
fun occurrenceChange(values: ProviderValues): OccurrenceChange? {
val v = Values(values.filterKeys { it in COMPLETION_COLUMNS })
return when {
Tasks.STATUS in v -> when (v.int(Tasks.STATUS, 0..3)) {
TasksContract.STATUS_COMPLETED -> OccurrenceChange.COMPLETE
TasksContract.STATUS_CANCELLED -> OccurrenceChange.CANCEL
TasksContract.STATUS_NEEDS_ACTION, null -> OccurrenceChange.REOPEN
else -> throw IllegalArgumentException("An occurrence is completed, cancelled or open, not in process")
}
Tasks.PERCENT_COMPLETE in v -> when (v.int(Tasks.PERCENT_COMPLETE, 0..100)) {
100 -> OccurrenceChange.COMPLETE
0, null -> OccurrenceChange.REOPEN
else -> throw IllegalArgumentException("An occurrence is completed or open; partial progress belongs to its task")
}
Tasks.COMPLETED in v -> if (v.instant(Tasks.COMPLETED) != null) OccurrenceChange.COMPLETE else OccurrenceChange.REOPEN
else -> null
}
}
/**
* Status, progress and completion time after a write. They move together the
* way OpenTasks keeps them: a status implies its progress and time, progress
* implies a status, and a completion time alone completes the task. When a
* row is written back, the column that changed decides, not the ones it
* carries unchanged.
*/
private fun completion(current: TaskEntity, values: Values, now: Instant): Triple<TaskStatus, Int?, Instant?> {
val v = Values(values.map.filterKeys { it !in COMPLETION_COLUMNS || it in completionChanged(current, values) })
val percent = if (Tasks.PERCENT_COMPLETE in v) v.int(Tasks.PERCENT_COMPLETE, 0..100) else current.percentComplete
val completed = if (Tasks.COMPLETED in v) v.instant(Tasks.COMPLETED) else current.completedAt
return when {
Tasks.STATUS in v -> when (val status = statusFromInt(v.int(Tasks.STATUS, 0..3))) {
TaskStatus.COMPLETED -> Triple(status, if (Tasks.PERCENT_COMPLETE in v) percent else 100, completed ?: now)
TaskStatus.NEEDS_ACTION ->
Triple(status, if (Tasks.PERCENT_COMPLETE in v) percent?.takeIf { it < 100 } else null, null)
else -> Triple(status, percent, null)
}
Tasks.PERCENT_COMPLETE in v -> when {
percent == null -> Triple(current.status, null, current.completedAt)
percent >= 100 -> Triple(TaskStatus.COMPLETED, percent, completed ?: now)
percent > 0 -> Triple(TaskStatus.IN_PROCESS, percent, null)
else -> Triple(TaskStatus.NEEDS_ACTION, percent, null)
}
Tasks.COMPLETED in v -> when {
completed != null -> Triple(TaskStatus.COMPLETED, 100, completed)
// Clearing the time reopens a completed task; an open one has none to clear.
current.status == TaskStatus.COMPLETED -> Triple(TaskStatus.NEEDS_ACTION, null, null)
else -> Triple(current.status, current.percentComplete, null)
}
else -> Triple(current.status, current.percentComplete, current.completedAt)
}
}
/**
* The completion columns in [v] that differ from [current]. All of them when
* none does, so a write that only repeats the row still settles the rest.
*/
private fun completionChanged(current: TaskEntity, v: Values): Set<String> {
val present = COMPLETION_COLUMNS.filterTo(HashSet()) { it in v }
val changed = present.filterTo(HashSet()) { column ->
when (column) {
Tasks.STATUS -> statusFromInt(v.int(Tasks.STATUS, 0..3)) != current.status
Tasks.PERCENT_COMPLETE -> v.int(Tasks.PERCENT_COMPLETE, 0..100) != current.percentComplete
else -> v.instant(Tasks.COMPLETED) != current.completedAt
}
}
return changed.ifEmpty { present }
}
/**
* An `RDATE`/`EXDATE` value: DATE or DATE-TIME values, comma-separated, of
* one kind. [stored] written back as is stays, whatever shape it synced in.
*/
private fun dateList(column: String, v: Values, stored: String?): String? {
val raw = v.string(column)?.trim()?.ifEmpty { null } ?: return null
if (raw == stored?.trim()) return stored
val values = raw.split(',').map(String::trim)
require(values.all { ICAL_DATE.matches(it) }) { "$column takes iCalendar dates or date-times: $raw" }
// lib-recur reads the whole list or none of it, so it has to be one kind.
val kinds = values.map { if (it.length == 8) "date" else if (it.endsWith('Z')) "utc" else "local" }
require(kinds.distinct().size == 1) { "$column mixes value kinds: $raw" }
return values.joinToString(",")
}
private fun normalizedRule(raw: String?): String? {
val rule = raw?.trim()?.removePrefix("RRULE:")?.ifEmpty { null } ?: return null
require(runCatching { RecurrenceRule(rule) }.isSuccess) { "Not a recurrence rule: $rule" }
return rule
}
}
/** Lenient reads over [values]: a client may hand a number as a string or a flag as an integer. */
internal class Values(private val values: ProviderValues) {
val keys: Set<String> get() = values.keys
val map: ProviderValues get() = values
operator fun contains(key: String) = key in values
fun string(key: String): String? = values[key]?.toString()
/** A free-text column: trimmed, blank stored as nothing. */
fun text(key: String): String? = string(key)?.trim()?.ifEmpty { null }
fun long(key: String): Long? = when (val value = values[key]) {
null -> null
is Number -> value.toLong()
is Boolean -> if (value) 1L else 0L
is String -> requireNotNull(value.trim().toLongOrNull()) { "$key is not a number: $value" }
else -> throw IllegalArgumentException("$key is not a number: $value")
}
fun int(key: String, range: IntRange? = null): Int? = long(key)?.let { value ->
require(value in Int.MIN_VALUE..Int.MAX_VALUE) { "$key is out of range: $value" }
value.toInt().also { require(range == null || it in range) { "$key must be in $range: $it" } }
}
fun boolean(key: String): Boolean = when (val value = values[key]) {
null -> false
is Boolean -> value
is String -> when (value.trim().lowercase()) {
"1", "true" -> true
"0", "false", "" -> false
else -> throw IllegalArgumentException("$key is not a flag: $value")
}
else -> long(key) != 0L
}
fun instant(key: String): Instant? = long(key)?.let(Instant::fromEpochMilliseconds)
}
@@ -0,0 +1,441 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
import android.database.sqlite.SQLiteConstraintException
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import de.jeanlucmakiola.agendula.data.tasks.TaskWriteFailedException
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Alarm
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Category
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import de.jeanlucmakiola.agendula.data.tasks.room.seriesOccurrences
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.normalizeTags
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.time.ZoneId
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Clock
import kotlin.time.Instant
/** One row by id, `<id column> = N` or `<id column> = ?`. */
private val SINGLE_ROW = Regex("""\s*(\w+)\s*=\s*(\d+|\?)\s*""")
/**
* Applies another app's writes to the own store for [AgendulaTaskProvider].
*
* A write's selection runs against the published snapshot, so it matches the
* rows the client can read, and each matched row is then written through
* [RoomTasksDataSource] under the rules the app's own edits follow. `tasklists`
* stays read-only. On `instances`, completion columns tick off that one
* occurrence of a series; other content columns edit the series, and a changed
* time column is refused, since one occurrence cannot be moved this way.
*
* Each call runs in one transaction, and a batch in one more around them.
*/
@Singleton
class ProviderWriter internal constructor(
private val database: TasksDatabase,
private val store: RoomTasksDataSource,
private val source: ProviderSnapshotSource,
private val resolver: ProviderResolver,
private val afterWrite: () -> Unit,
private val clock: Clock = Clock.System,
) {
@Inject constructor(
database: TasksDatabase,
store: RoomTasksDataSource,
source: ProviderSnapshotSource,
resolver: ProviderResolver,
scheduler: ReminderScheduler,
@ApplicationScope scope: CoroutineScope,
) : this(database, store, source, resolver, { scope.launch { runCatching { scheduler.sync() } } })
private var batchDepth = 0
private var batchWrote = false
/** Inserts one row and returns its published id. */
fun insert(table: ProviderTable, values: ProviderValues): Long = write {
when (table) {
ProviderTable.TASKS -> insertTask(values)
ProviderTable.PROPERTIES -> insertProperty(values)
else -> throw UnsupportedOperationException("${table.path} is read-only")
}
}
/** Updates every row [selection] matches and returns how many changed. */
fun update(table: ProviderTable, values: ProviderValues, selection: String?, args: Array<out String>?): Int = write {
when (table) {
ProviderTable.TASKS -> {
val writable = ProviderTaskWriter.writable(table, values, WRITABLE_TASK_COLUMNS)
val matched = ids(table, selection, args)
(if (Tasks.LIST_ID in writable) parentsFirst(matched) else matched).count { updateTask(it, writable) }
}
ProviderTable.INSTANCES -> {
val writable = ProviderTaskWriter.writable(table, values, WRITABLE_TASK_COLUMNS)
instances(selection, args).count { updateInstance(it, writable) }
}
ProviderTable.PROPERTIES -> ids(table, selection, args).count { updateProperty(it, values) }
else -> throw UnsupportedOperationException("${table.path} is read-only")
}
}
/** Deletes every row [selection] matches and returns how many went. */
fun delete(table: ProviderTable, selection: String?, args: Array<out String>?): Int = write {
when (table) {
ProviderTable.TASKS -> ids(table, selection, args).count { deleteTask(it) }
ProviderTable.INSTANCES -> instances(selection, args).count { deleteInstance(it) }
ProviderTable.PROPERTIES -> ids(table, selection, args).count { deleteProperty(it) }
else -> throw UnsupportedOperationException("${table.path} is read-only")
}
}
/** Runs a client's batch in one transaction, re-arming reminders once at the end. */
fun <T> batch(block: () -> T): T = source.locked {
batchDepth++
try {
database.runInTransaction<T> { block() }
} finally {
// A snapshot built mid-batch may hold rows a failure rolled back.
source.invalidate()
batchDepth--
if (batchDepth == 0 && batchWrote) {
batchWrote = false
afterWrite()
}
}
}
private fun <T> write(block: () -> T): T = source.locked {
check(resolver.mode() == StorageMode.OWN) {
"Agendula is working on top of an external tasks provider; write to that one instead"
}
val result = try {
database.runInTransaction<T> { block() }
} catch (e: TaskWriteFailedException) {
throw IllegalArgumentException(e.message, e)
} catch (e: SQLiteConstraintException) {
throw IllegalArgumentException(e.message, e)
} finally {
source.invalidate()
}
if (batchDepth == 0) afterWrite() else batchWrote = true
result
}
// --- tasks ------------------------------------------------------------------
private fun insertTask(values: ProviderValues): Long {
require(values[Tasks.ORIGINAL_INSTANCE_ID] == null && values[Tasks.ORIGINAL_INSTANCE_TIME] == null) {
"Modify an occurrence through ${Instances.PATH}, not by inserting one"
}
val writable = ProviderTaskWriter.writable(ProviderTable.TASKS, values, WRITABLE_TASK_COLUMNS)
val task = ProviderTaskWriter.newTask(writable, UUID.randomUUID().toString(), clock.now(), zone())
requireWritableList(task.listId)
// The unique index cannot catch this: SQLite treats the masters' NULL recurrence_id as distinct.
require(database.tasks().byUid(task.listId, task.uid) == null) { "List ${task.listId} already has a task ${task.uid}" }
requireParent(task)
return store.insertEntity(task)
}
private fun updateTask(taskId: Long, values: ProviderValues): Boolean {
val current = database.tasks().entity(taskId)?.takeUnless { it.isDeleted } ?: return false
requireWritableList(current.listId)
val edited = ProviderTaskWriter.patch(current, values, clock.now(), zone())
if (edited == current) return true
val moved = edited.listId != current.listId
if (moved) {
requireWritableList(edited.listId)
requireFreeUid(edited.listId, current.uid)
}
if (edited.parentId != current.parentId || moved) requireParent(edited)
store.replaceEntity(current, edited)
// A subtree moves as one: a subtask has to be in its parent's list.
if (moved) store.subtaskIds(taskId).forEach { moveSubtree(it, edited.listId, hashSetOf(taskId)) }
return true
}
private fun moveSubtree(taskId: Long, listId: Long, seen: MutableSet<Long> = HashSet()) {
if (!seen.add(taskId)) return
val current = database.tasks().entity(taskId)?.takeUnless { it.isDeleted } ?: return
if (current.listId != listId) {
requireFreeUid(listId, current.uid)
store.replaceEntity(current, current.copy(listId = listId, lastModified = clock.now(), isDirty = true))
}
store.subtaskIds(taskId).forEach { moveSubtree(it, listId, seen) }
}
/**
* [ids] with every parent before its subtasks, so a bulk move carries a
* subtree along before reaching its subtasks, whatever order they came in.
*/
private fun parentsFirst(ids: List<Long>): List<Long> {
if (ids.size < 2) return ids
fun depth(id: Long): Int {
val seen = HashSet<Long>()
var parent = database.tasks().entity(id)?.parentId
while (parent != null && seen.add(parent)) parent = database.tasks().entity(parent)?.parentId
return seen.size
}
val depths = ids.associateWith(::depth)
return ids.sortedBy(depths::getValue)
}
private fun deleteTask(taskId: Long): Boolean {
val current = database.tasks().entity(taskId)?.takeUnless { it.isDeleted } ?: return false
requireWritableList(current.listId)
// An override deletes itself into an EXDATE on its master.
if (current.masterId != null) store.deleteTask(taskId) else store.deleteWithSubtasks(taskId)
return true
}
// --- instances --------------------------------------------------------------
/** One occurrence: the row it reads from, its series, and when it was due to happen. */
private class InstanceRow(val taskId: Long, val seriesId: Long, val originalTime: Instant?, val recurring: Boolean)
private fun instances(selection: String?, args: Array<out String>?): List<InstanceRow> {
singleId(ProviderTable.INSTANCES, selection, args)?.let { return listOfNotNull(instanceRow(it)) }
return source.rows(
ProviderTable.INSTANCES,
listOf(Tasks.ID, Instances.TASK_ID, Instances.INSTANCE_ORIGINAL_TIME, Instances.IS_RECURRING),
selection,
args,
).map { (id, taskId, original, recurring) ->
InstanceRow(
taskId = taskId as Long,
seriesId = ProviderIds.instanceSeries(id as Long),
originalTime = (original as Long?)?.let(Instant::fromEpochMilliseconds),
recurring = recurring == 1L,
)
}
}
/** [instanceId] read straight from the store, without rebuilding the snapshot. */
private fun instanceRow(instanceId: Long): InstanceRow? {
val master = database.tasks().entity(ProviderIds.instanceSeries(instanceId))
?.takeIf { !it.isDeleted && it.masterId == null } ?: return null
val occurrences = seriesOccurrences(master, database.tasks().overrides(master.id), clock.now())
?: return InstanceRow(master.id, master.id, null, recurring = false)
.takeIf { ProviderIds.instanceSeconds(instanceId) == 0L }
val occurrence = occurrences.firstOrNull { ProviderIds.instance(master.id, it.anchor) == instanceId } ?: return null
return InstanceRow(occurrence.override?.id ?: master.id, master.id, occurrence.anchor, recurring = true)
}
private fun updateInstance(row: InstanceRow, values: ProviderValues): Boolean {
val occurrence = row.originalTime
if (!row.recurring || occurrence == null) return updateTask(row.taskId, values)
val shown = database.tasks().entity(row.taskId)?.takeUnless { it.isDeleted } ?: return false
requireWritableList(shown.listId)
// A row read back carries every column; only what differs from it is a write.
val changed = ProviderTaskWriter.changedColumns(shown, values, zone())
val moved = changed intersect TIME_COLUMNS
if (moved.isNotEmpty()) {
// A bad value reports itself rather than as a move.
ProviderTaskWriter.patch(shown, values.filterKeys { it in moved }, clock.now(), zone())
throw IllegalArgumentException(
"One occurrence of a recurring task cannot be moved (${moved.sorted().joinToString()}); write the series through ${Tasks.PATH}",
)
}
val content = values.filterKeys { it in changed && it !in COMPLETION_COLUMNS }
if (content.isNotEmpty()) {
updateTask(row.seriesId, content)
// A modified occurrence shows its own copy of the content, so it takes the write too.
if (row.taskId != row.seriesId) updateTask(row.taskId, content - Tasks.LIST_ID - Tasks.PARENT_ID)
}
if (changed.none { it in COMPLETION_COLUMNS }) return true
// The column the client changed decides, not the unchanged ones it carries along.
val change = ProviderTaskWriter.occurrenceChange(values.filterKeys { it in changed && it in COMPLETION_COLUMNS })
?: return true
val status = database.tasks().entity(row.taskId)?.status
when (change) {
OccurrenceChange.COMPLETE ->
if (status != TaskStatus.COMPLETED) store.setCompletedInstance(row.taskId, occurrence, true)
OccurrenceChange.CANCEL ->
if (status != TaskStatus.CANCELLED) store.setCancelledInstance(row.taskId, occurrence, true)
OccurrenceChange.REOPEN ->
if (status != TaskStatus.NEEDS_ACTION) store.setCompletedInstance(row.taskId, occurrence, false)
}
return true
}
private fun deleteInstance(row: InstanceRow): Boolean {
val occurrence = row.originalTime
if (!row.recurring || occurrence == null) return deleteTask(row.taskId)
val task = database.tasks().entity(row.taskId)?.takeUnless { it.isDeleted } ?: return false
requireWritableList(task.listId)
if (task.masterId != null) store.deleteTask(task.id) else store.deleteOccurrence(task.id, occurrence)
return true
}
// --- properties -------------------------------------------------------------
/** What a published property id stands for, read from the store. */
private sealed interface PropertyRef {
val task: TaskEntity
class AlarmRow(val alarm: TaskAlarmEntity, override val task: TaskEntity) : PropertyRef
class CategoryRow(val name: String, override val task: TaskEntity) : PropertyRef
}
private fun propertyRef(propertyId: Long): PropertyRef? {
if (ProviderIds.isAlarm(propertyId)) {
val alarm = database.alarms().entity(ProviderIds.alarmRow(propertyId)) ?: return null
val task = database.tasks().entity(alarm.taskId)?.takeUnless { it.isDeleted } ?: return null
return PropertyRef.AlarmRow(alarm, task)
}
val task = database.tasks().entity(ProviderIds.categoryTask(propertyId))?.takeUnless { it.isDeleted } ?: return null
val name = ProviderIds.categories(task.id, task.categories).entries.firstOrNull { it.value == propertyId }?.key
?: return null
return PropertyRef.CategoryRow(name, task)
}
private fun insertProperty(values: ProviderValues): Long {
val v = Values(ProviderTaskWriter.writable(ProviderTable.PROPERTIES, values, values.keys))
val taskId = requireNotNull(v.long(Properties.TASK_ID)) { "A property needs a ${Properties.TASK_ID}" }
val task = liveTask(taskId)
return when (v.string(Properties.MIMETYPE)) {
Alarm.MIMETYPE -> {
val alarm = alarm(TaskAlarmEntity(taskId = taskId, minutesBefore = 0), v, requireMinutes = true)
var id = 0L
store.writeAlarm(taskId) { id = database.alarms().insert(alarm) }
ProviderIds.alarm(id)
}
Category.MIMETYPE -> {
val name = requireNotNull(v.text(Category.NAME)) { "A category needs a ${Category.NAME}" }
setCategories(task, task.categories + name)
val categories = database.tasks().entity(taskId)!!.categories
ProviderIds.categories(taskId, categories).getValue(name)
}
else -> throw IllegalArgumentException("Only alarm and category properties can be written")
}
}
private fun updateProperty(propertyId: Long, values: ProviderValues): Boolean {
val v = Values(ProviderTaskWriter.writable(ProviderTable.PROPERTIES, values, values.keys - Properties.PROPERTY_ID))
val ref = propertyRef(propertyId) ?: return false
requireWritableList(ref.task.listId)
requireSame(v, Properties.TASK_ID, ref.task.id)
val mimetype = if (ref is PropertyRef.AlarmRow) Alarm.MIMETYPE else Category.MIMETYPE
if (Properties.MIMETYPE in v) require(v.string(Properties.MIMETYPE) == mimetype) { "A property's mimetype cannot change" }
when (ref) {
is PropertyRef.AlarmRow -> {
val edited = alarm(ref.alarm, v, requireMinutes = false)
if (edited != ref.alarm) store.writeAlarm(ref.task.id) { database.alarms().update(edited) }
}
is PropertyRef.CategoryRow -> if (Category.NAME in v) {
val name = requireNotNull(v.text(Category.NAME)) { "A category needs a ${Category.NAME}" }
setCategories(ref.task, ref.task.categories.map { if (it == ref.name) name else it })
}
}
return true
}
private fun deleteProperty(propertyId: Long): Boolean {
val ref = propertyRef(propertyId) ?: return false
requireWritableList(ref.task.listId)
when (ref) {
is PropertyRef.AlarmRow -> store.writeAlarm(ref.task.id) { database.alarms().delete(ref.alarm.id) }
is PropertyRef.CategoryRow -> setCategories(ref.task, ref.task.categories - ref.name)
}
return true
}
private fun alarm(base: TaskAlarmEntity, v: Values, requireMinutes: Boolean): TaskAlarmEntity {
if (requireMinutes) requireNotNull(v.int(Alarm.MINUTES_BEFORE)) { "An alarm needs ${Alarm.MINUTES_BEFORE}" }
val reference = when (if (Alarm.REFERENCE in v) v.int(Alarm.REFERENCE) else null) {
null -> base.reference
Alarm.REFERENCE_DUE -> AlarmReference.DUE
Alarm.REFERENCE_START -> AlarmReference.START
else -> throw IllegalArgumentException("${Alarm.REFERENCE} must be ${Alarm.REFERENCE_DUE} (due) or ${Alarm.REFERENCE_START} (start)")
}
return base.copy(
minutesBefore = if (Alarm.MINUTES_BEFORE in v) requireNotNull(v.int(Alarm.MINUTES_BEFORE)) {
"${Alarm.MINUTES_BEFORE} cannot be null"
} else base.minutesBefore,
reference = reference,
message = if (Alarm.MESSAGE in v) v.text(Alarm.MESSAGE) else base.message,
)
}
private fun setCategories(task: TaskEntity, categories: List<String>) {
val normalized = normalizeTags(categories)
if (normalized == task.categories) return
store.replaceEntity(task, task.copy(categories = normalized, lastModified = clock.now(), isDirty = true))
}
// --- checks -----------------------------------------------------------------
/**
* The ids [selection] matches. A row URI's plain `<id column> = N` is checked
* against the store directly; anything else runs against the snapshot.
*/
private fun ids(table: ProviderTable, selection: String?, args: Array<out String>?): List<Long> {
singleId(table, selection, args)?.let { id ->
val exists = when (table) {
ProviderTable.TASKS -> database.tasks().entity(id)?.isDeleted == false
ProviderTable.PROPERTIES -> propertyRef(id) != null
else -> true
}
return listOfNotNull(id.takeIf { exists })
}
return source.rows(table, listOf(table.idColumn), selection, args).map { it.single() as Long }
}
private fun singleId(table: ProviderTable, selection: String?, args: Array<out String>?): Long? {
val match = SINGLE_ROW.matchEntire(selection ?: return null) ?: return null
if (match.groupValues[1] != table.idColumn) return null
val value = match.groupValues[2]
return when {
value == "?" -> args?.singleOrNull()?.trim()?.toLongOrNull()
args.isNullOrEmpty() -> value.toLongOrNull()
else -> null
}
}
private fun requireFreeUid(listId: Long, uid: String) {
require(database.tasks().byUid(listId, uid)?.isDeleted != false) { "List $listId already has a task $uid" }
}
private fun liveTask(taskId: Long): TaskEntity {
val task = requireNotNull(database.tasks().entity(taskId)?.takeUnless { it.isDeleted }) { "No task $taskId" }
requireWritableList(task.listId)
return task
}
private fun requireWritableList(listId: Long) {
val list = requireNotNull(database.taskLists().entity(listId)) { "No task list $listId" }
if (list.isReadOnly) throw UnsupportedOperationException("Task list $listId is read-only")
}
/** A parent has to be a live, top-level task in the same list, and not one of the task's own subtasks. */
private fun requireParent(task: TaskEntity) {
var parentId = task.parentId ?: return
val parent = database.tasks().entity(parentId)?.takeUnless { it.isDeleted }
require(parent != null && parent.masterId == null) { "No task $parentId to be a parent" }
require(parent.listId == task.listId) { "A subtask has to be in its parent's list" }
val seen = HashSet<Long>()
while (true) {
require(parentId != task.id) { "Task ${task.id} cannot be its own ancestor" }
if (!seen.add(parentId)) break
parentId = database.tasks().entity(parentId)?.parentId ?: break
}
}
private fun requireSame(v: Values, column: String, expected: Long) {
if (column in v) require(v.long(column) == expected) { "A property's $column cannot change" }
}
private fun zone(): String = ZoneId.systemDefault().id
}
@@ -0,0 +1,88 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
import android.content.ContentValues
import android.database.Cursor
import android.database.MatrixCursor
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteQueryBuilder
import android.os.Build
import androidx.core.database.sqlite.transaction
/**
* An in-memory SQLite copy of a [ProviderSnapshot], so a client's own
* `selection`, `selectionArgs` and `sortOrder` run as SQL exactly as they would
* against OpenTasks. Holds only what the caller may already read.
*
* Not thread-safe; [ProviderSnapshotSource] serialises access.
*/
internal class SnapshotDatabase {
private val db: SQLiteDatabase = SQLiteDatabase.create(null).apply {
ProviderTable.entries.forEach { table ->
execSQL(
"CREATE TABLE ${table.path} (" +
table.columns.joinToString { (name, type) -> "$name $type" } + ")",
)
}
}
fun load(snapshot: ProviderSnapshot) {
db.transaction {
ProviderTable.entries.forEach { table ->
delete(table.path, null, null)
snapshot.rows[table].orEmpty().forEach { insertOrThrow(table.path, null, it.toContentValues()) }
}
}
}
/**
* Runs the query and copies the result out, so the cursor a client holds
* survives the next [load].
*/
fun query(
table: ProviderTable,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?,
): Cursor {
val builder = SQLiteQueryBuilder().apply {
tables = table.path
isStrict = true
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
isStrictColumns = true
isStrictGrammar = true
}
projectionMap = table.columns.associate { (name, _) -> name to name }
}
builder.query(db, projection, selection, selectionArgs, null, null, sortOrder).use { source ->
val copy = MatrixCursor(source.columnNames, source.count)
while (source.moveToNext()) {
copy.addRow(
Array(source.columnCount) { column ->
when (source.getType(column)) {
Cursor.FIELD_TYPE_NULL -> null
Cursor.FIELD_TYPE_INTEGER -> source.getLong(column)
Cursor.FIELD_TYPE_FLOAT -> source.getDouble(column)
Cursor.FIELD_TYPE_BLOB -> source.getBlob(column)
else -> source.getString(column)
}
},
)
}
return copy
}
}
private fun ProviderRow.toContentValues() = ContentValues(size).also { values ->
forEach { (key, value) ->
when (value) {
null -> values.putNull(key)
is Long -> values.put(key, value)
is Int -> values.put(key, value)
is String -> values.put(key, value)
else -> values.put(key, value.toString())
}
}
}
}
@@ -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 de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
import de.jeanlucmakiola.agendula.domain.normalizeTags
import de.jeanlucmakiola.agendula.domain.statusFromInt
import de.jeanlucmakiola.agendula.domain.toInt
import kotlin.time.Instant
@@ -36,3 +38,17 @@ object Converters {
fun alarmReferenceFrom(value: String): AlarmReference =
runCatching { AlarmReference.valueOf(value) }.getOrDefault(AlarmReference.DUE)
}
/**
* [TaskEntity.categories] stored as a `CATEGORIES` value, so escaping has one
* home; empty when there are none. Scoped to that field rather than the
* database, so no other `List<String>` picks up iCalendar escaping by accident.
*/
object CategoriesConverter {
@TypeConverter
fun toText(value: List<String>): String = ICalValues.formatTextList(normalizeTags(value))
@TypeConverter
fun fromText(value: String): List<String> =
if (value.isEmpty()) emptyList() else normalizeTags(ICalValues.parseTextList(value))
}
@@ -5,6 +5,7 @@ import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import androidx.room.TypeConverters
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
import de.jeanlucmakiola.agendula.domain.TaskStatus
import kotlin.time.Instant
@@ -143,6 +144,10 @@ data class TaskEntity(
@ColumnInfo(name = "url") val url: String? = null,
/** ARGB override for the list colour. */
@ColumnInfo(name = "color") val color: Int? = null,
/** RFC 5545 `CATEGORIES` — the tags — in the spelling each task carries them. */
@ColumnInfo(name = "categories", defaultValue = "")
@field:TypeConverters(CategoriesConverter::class)
val categories: List<String> = emptyList(),
// state
@ColumnInfo(name = "status", defaultValue = "0") val status: TaskStatus = TaskStatus.NEEDS_ACTION,
@@ -49,6 +49,7 @@ object RoomTaskMapper {
description = row.task.description,
location = row.task.location,
url = row.task.url,
tags = row.task.categories,
priority = priorityFromICal(row.task.priority),
status = row.task.status,
percentComplete = row.task.percentComplete,
@@ -79,6 +80,7 @@ object RoomTaskMapper {
description = task.description,
location = task.location,
url = task.url,
tags = task.categories,
priority = priorityFromICal(task.priority),
status = task.status,
percentComplete = task.percentComplete,
@@ -31,7 +31,51 @@ private val SERIES_LINES = listOf("EXDATE", "RDATE", "RRULE", "RECURRENCE-ID", "
/** Enough to count every occurrence before a split point without bounding a real series. */
internal const val SPLIT_COUNT_CEILING = 100_000
private val OBSERVED_TABLES = arrayOf("tasks", "task_lists", "task_alarms", "accounts")
/** The window every read of the store expands a series over. */
internal fun expansionWindow(now: Instant) = ExpansionWindow(
from = now - WINDOW_BACK,
until = now + WINDOW_FORWARD,
pivot = now,
)
/**
* One occurrence's resolved start and due. A timed series keeps each
* occurrence's duration; a due-anchored one has no start to offset from, so
* the anchor *is* the due date.
*/
internal fun occurrenceTimes(master: TaskEntity, anchor: Instant): Pair<Instant?, Instant?> {
if (master.dtstart == null) return null to anchor
val length = master.due?.let { it - master.dtstart }
return anchor to length?.let { anchor + it }
}
/** One occurrence of a series: its anchor, the override replacing it if any, and its resolved times. */
internal class SeriesOccurrence(
val anchor: Instant,
val override: TaskEntity?,
val start: Instant?,
val due: Instant?,
val distance: Int?,
)
/**
* Every occurrence of [master] inside the expansion window, with any
* `RECURRENCE-ID` override substituted for the occurrence it replaces, or
* `null` when [master] does not recur.
*/
internal fun seriesOccurrences(master: TaskEntity, overrides: List<TaskEntity>, now: Instant): List<SeriesOccurrence>? {
val spec = master.recurrenceSpec() ?: return null
val anchors = RecurrenceExpander.expand(spec, expansionWindow(now))
val distances = RecurrenceExpander.distancesFromCurrent(anchors, now)
val byAnchor = overrides.associateBy { it.recurrenceId }
return anchors.mapIndexed { index, anchor ->
val override = byAnchor[anchor]
val (start, due) = if (override != null) override.dtstart to override.due else occurrenceTimes(master, anchor)
SeriesOccurrence(anchor, override, start, due, distances.getOrNull(index))
}
}
internal val OBSERVED_TABLES = arrayOf("tasks", "task_lists", "task_alarms", "accounts")
/**
* [TasksDataSource] over Agendula's own Room store.
@@ -101,6 +145,12 @@ class RoomTasksDataSource @Inject constructor(
.flatMap { occurrencesOf(it, tasks.overrides(it.task.id), now) }
}
/** One transaction, so a failure part-way leaves the subtree whole. */
override fun deleteWithSubtasks(taskId: Long) = tx { super.deleteWithSubtasks(taskId) }
override fun subtaskIds(parentTaskId: Long): List<Long> =
tasks.subtasks(parentTaskId).map { it.task.id }.filter { it != parentTaskId }
override fun exportTasks(listId: Long): List<ExportTask> =
tasks.exportTasks(listId).map(RoomTaskMapper::exportTask)
@@ -123,54 +173,31 @@ class RoomTasksDataSource @Inject constructor(
* [Task.occurrenceStart], so it keys and edits by task id exactly as before.
*/
private fun occurrencesOf(row: TaskRow, overrides: List<TaskEntity>, now: Instant): List<Task> {
val spec = row.task.recurrenceSpec() ?: return listOf(RoomTaskMapper.task(row))
val window = ExpansionWindow(
from = now - WINDOW_BACK,
until = now + WINDOW_FORWARD,
pivot = now,
)
val anchors = RecurrenceExpander.expand(spec, window)
if (anchors.isEmpty()) return emptyList()
val distances = RecurrenceExpander.distancesFromCurrent(anchors, now)
val byAnchor = overrides.associateBy { it.recurrenceId }
return anchors.mapIndexedNotNull { index, anchor ->
val override = byAnchor[anchor]
val occurrences = seriesOccurrences(row.task, overrides, now) ?: return listOf(RoomTaskMapper.task(row))
return occurrences.map { occurrence ->
val override = occurrence.override
if (override != null) {
RoomTaskMapper.task(
row = row.copy(task = override),
occurrenceStart = anchor,
start = override.dtstart,
due = override.due,
distanceFromCurrent = distances[index],
occurrenceStart = occurrence.anchor,
start = occurrence.start,
due = occurrence.due,
distanceFromCurrent = occurrence.distance,
seriesId = row.task.id,
recurrenceRule = row.task.rrule,
)
} else {
val (start, due) = occurrenceTimes(row.task, anchor)
RoomTaskMapper.task(
row = row,
occurrenceStart = anchor,
start = start,
due = due,
distanceFromCurrent = distances[index],
occurrenceStart = occurrence.anchor,
start = occurrence.start,
due = occurrence.due,
distanceFromCurrent = occurrence.distance,
)
}
}
}
/**
* One occurrence's resolved start and due. A timed series keeps each
* occurrence's duration; a due-anchored one has no start to offset from, so
* the anchor *is* the due date.
*/
private fun occurrenceTimes(master: TaskEntity, anchor: Instant): Pair<Instant?, Instant?> {
if (master.dtstart == null) return null to anchor
val length = master.due?.let { it - master.dtstart }
return anchor to length?.let { anchor + it }
}
// --- writes ---------------------------------------------------------------
override fun insertTask(form: TaskForm): Long {
@@ -322,8 +349,7 @@ class RoomTasksDataSource @Inject constructor(
val master = tasks.entity(seriesId) ?: return@tx
val anchor = master.recurrenceAnchor
if (anchor == null || occurrenceStart <= anchor) {
tasks.subtasks(seriesId).forEach { deleteTask(it.task.id) }
deleteTask(seriesId)
deleteWithSubtasks(seriesId)
return@tx
}
tasks.update(TaskFormWriter.endedBefore(master, occurrenceStart, clock.now(), zone()))
@@ -515,6 +541,47 @@ class RoomTasksDataSource @Inject constructor(
writes.onWritten(setOf(current.listId))
}
// --- whole-row writes, for the published provider --------------------------
/** Inserts [entity] as it stands, for a client that wrote the row itself. */
internal fun insertEntity(entity: TaskEntity): Long {
if (lists.exists(entity.listId) == 0) throw TaskWriteFailedException("insert task: no list ${entity.listId}")
return tasks.insert(entity).also { writes.onWritten(setOf(entity.listId)) }
}
/**
* Writes [edited] over [current] under the rules [updateTask] and
* [updateSeries] keep: a task moves lists through [TaskMove], an override
* keeps its master's list and parent, and a series whose anchor or rule
* changed drops the overrides it no longer generates.
*/
internal fun replaceEntity(current: TaskEntity, edited: TaskEntity) {
tx {
val reshaped = current.masterId == null && current.recurrenceSpec() != null &&
(edited.dtstart != current.dtstart || edited.due != current.due || edited.rrule != current.rrule)
if (reshaped) tasks.overrides(current.id).forEach { tasks.delete(it.id) }
when {
current.masterId != null -> tasks.update(edited.copy(listId = current.listId, parentId = current.parentId))
edited.listId != current.listId -> {
if (lists.exists(edited.listId) == 0) throw TaskWriteFailedException("move task: no list ${edited.listId}")
move(current, edited)
}
else -> tasks.update(edited)
}
}
writes.onWritten(setOf(current.listId, edited.listId))
}
/** Adds, changes or removes one reminder, and marks its task changed so the reminder syncs. */
internal fun writeAlarm(taskId: Long, write: () -> Unit) {
val task = tasks.entity(taskId) ?: throw TaskWriteFailedException("alarm on task $taskId")
tx {
write()
tasks.update(task.copy(lastModified = clock.now(), isDirty = true))
}
writes.onWritten(setOf(task.listId))
}
override fun createLocalList(name: String, color: Int): Long =
lists.insert(TaskListEntity(name = name.trim(), color = color))
@@ -4,6 +4,7 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
/** Reads and writes over `task_alarms`. */
@Dao
@@ -16,9 +17,15 @@ interface TaskAlarmDao {
@Query("SELECT * FROM task_alarms WHERE task_id = :taskId ORDER BY id")
fun forTask(taskId: Long): List<TaskAlarmEntity>
@Query("SELECT * FROM task_alarms WHERE id = :id")
fun entity(id: Long): TaskAlarmEntity?
@Insert
fun insert(alarm: TaskAlarmEntity): Long
@Update
fun update(alarm: TaskAlarmEntity): Int
@Query("DELETE FROM task_alarms WHERE id = :id")
fun delete(id: Long): Int
@@ -105,6 +105,10 @@ interface TaskDao {
@Query("SELECT * FROM tasks WHERE list_id = :listId AND is_deleted = 0 AND master_id IS NULL")
fun exportTasks(listId: Long): List<TaskEntity>
/** Every row but the tombstones, overrides included — what the published provider lays out. */
@Query("SELECT * FROM tasks WHERE is_deleted = 0")
fun live(): List<TaskEntity>
@Query("SELECT * FROM tasks WHERE is_dirty = 1")
fun dirty(): List<TaskEntity>
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.agendula.data.tasks.room
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
import de.jeanlucmakiola.agendula.domain.normalizeTags
import de.jeanlucmakiola.agendula.domain.toICal
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.seconds
@@ -50,6 +51,7 @@ object TaskFormWriter {
description = form.description?.trim()?.ifBlank { null },
location = form.location?.trim()?.ifBlank { null },
url = form.url?.trim()?.ifBlank { null },
categories = normalizeTags(form.tags),
priority = form.priority.toICal(),
percentComplete = percent,
status = statusFor(percent, current.status),
@@ -3,6 +3,8 @@ package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
/**
* Agendula's own task store. Four tables, designed from
@@ -18,7 +20,7 @@ import androidx.room.TypeConverters
TaskEntity::class,
TaskAlarmEntity::class,
],
version = 1,
version = 2,
exportSchema = true,
)
@TypeConverters(Converters::class)
@@ -30,5 +32,36 @@ abstract class TasksDatabase : RoomDatabase() {
companion object {
const val NAME = "agendula-tasks.db"
/**
* v2 adds `categories`. Until then `CATEGORIES` lived in the residue, and
* an unchanged task is never downloaded again, so the values are lifted
* out here. Not marked dirty: nothing changed on the server's side.
*/
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE tasks ADD COLUMN categories TEXT NOT NULL DEFAULT ''")
val lifted = mutableListOf<Pair<Long, CategoriesLift.Result>>()
db.query(
"SELECT id, unknown_properties FROM tasks WHERE unknown_properties LIKE '%CATEGORIES%'",
).use { cursor ->
while (cursor.moveToNext()) {
val result = CategoriesLift.lift(cursor.getString(1))
if (result.categories.isNotEmpty()) lifted += cursor.getLong(0) to result
}
}
lifted.forEach { (id, result) ->
val categories = CategoriesConverter.toText(result.categories)
if (result.claimed) {
db.execSQL(
"UPDATE tasks SET categories = ?, unknown_properties = ? WHERE id = ?",
arrayOf<Any?>(categories, result.residue, id),
)
} else {
db.execSQL("UPDATE tasks SET categories = ? WHERE id = ?", arrayOf<Any?>(categories, id))
}
}
}
}
}
}
@@ -16,6 +16,7 @@ import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.export.ExportTask
import de.jeanlucmakiola.agendula.domain.normalizeTags
import de.jeanlucmakiola.agendula.domain.toICal
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
@@ -213,6 +214,7 @@ class ExternalImport @Inject constructor(
description = description,
location = location,
url = url,
categories = normalizeTags(tags),
status = status,
percentComplete = percentComplete,
completedAt = completedAt,
@@ -52,6 +52,8 @@ data class Task(
val description: String?,
val location: String?,
val url: String?,
/** `CATEGORIES`, as the task spells them. */
val tags: List<String> = emptyList(),
val priority: Priority,
val status: TaskStatus,
val percentComplete: Int?,
@@ -161,3 +163,10 @@ fun TaskStatus.toInt(): Int = when (this) {
TaskStatus.COMPLETED -> ICalStatus.COMPLETED
TaskStatus.CANCELLED -> ICalStatus.CANCELLED
}
/** Tags as every writer stores them: trimmed, no blanks, each spelling once, order kept. */
fun normalizeTags(tags: List<String>): List<String> = tags.map(String::trim).filter(String::isNotEmpty).distinct()
/** Every tag on [tasks], each spelling once, alphabetically ignoring case. */
fun tagsInUse(tasks: Iterable<Task>): List<String> =
tasks.flatMap { it.tags }.distinct().sortedWith(String.CASE_INSENSITIVE_ORDER)
@@ -0,0 +1,96 @@
package de.jeanlucmakiola.agendula.domain
import java.time.LocalDate
import java.time.ZoneId
import kotlin.time.Instant
/** Where an open task's due date falls, relative to today. */
enum class DueWindow { OVERDUE, TODAY, UPCOMING, NO_DATE }
/** How a [SavedFilter] with several tags reads them. */
enum class TagMatch { ANY, ALL }
/**
* A user-made view over the open tasks. An empty criterion doesn't narrow:
* no [due] windows means any due date, no [listIds] any list, no [tags] any tag.
* [minPriority] [Priority.NONE] lets every priority through.
*
* [listsElsewhere] is set when the filter also has lists in another task store.
* With none of its lists here it then matches nothing rather than every list.
*/
data class SavedFilter(
val id: Long,
val name: String,
val due: Set<DueWindow> = emptySet(),
val listIds: Set<Long> = emptySet(),
val tags: Set<String> = emptySet(),
val tagMatch: TagMatch = TagMatch.ANY,
val minPriority: Priority = Priority.NONE,
val listsElsewhere: Boolean = false,
) {
/** Whether the filter narrows by list at all. */
val anyList: Boolean get() = listIds.isEmpty() && !listsElsewhere
/** This filter without the list ids missing from [existing], e.g. lists since deleted. */
fun withListsIn(existing: Set<Long>): SavedFilter = copy(listIds = listIds intersect existing)
fun matches(task: Task, todayStart: Instant, todayEnd: Instant): Boolean {
if (task.isClosed) return false
if (listsElsewhere && listIds.isEmpty()) return false
if (listIds.isNotEmpty() && task.listId !in listIds) return false
if (task.priority < minPriority) return false
if (due.isNotEmpty() && dueWindowOf(task, todayStart, todayEnd) !in due) return false
return when {
tags.isEmpty() -> true
tagMatch == TagMatch.ALL -> task.tags.containsAll(tags)
else -> task.tags.any { it in tags }
}
}
}
/**
* Where [task]'s due falls against local today ([todayStart] to [todayEnd], from
* [DayWindow] in [zone]). An all-day due is stored as UTC midnight, so it is read
* as its calendar date first rather than compared as a raw instant.
*/
fun dueWindowOf(
task: Task,
todayStart: Instant,
todayEnd: Instant,
zone: ZoneId = ZoneId.systemDefault(),
): DueWindow {
val due = task.due?.let { if (task.isAllDay) it.rebasedForAllDay(allDay = false, zone = zone) else it }
return when {
due == null -> DueWindow.NO_DATE
due < todayStart -> DueWindow.OVERDUE
due < todayEnd -> DueWindow.TODAY
else -> DueWindow.UPCOMING
}
}
/** What a task created from a [SavedFilter] starts with, so it lands in that filter. */
data class NewTaskPreset(
val listIds: Set<Long> = emptySet(),
val tags: List<String> = emptyList(),
val dueDate: LocalDate? = null,
val priority: Priority = Priority.NONE,
)
/**
* The preset for a new task in this filter. Due prefers today, then tomorrow;
* a filter that admits undated tasks, or only overdue ones, leaves it empty.
*/
fun SavedFilter.newTaskPreset(today: LocalDate): NewTaskPreset = NewTaskPreset(
listIds = listIds,
tags = when {
tagMatch == TagMatch.ALL -> tags.sorted()
else -> listOfNotNull(tags.minOrNull())
},
dueDate = when {
due.isEmpty() || DueWindow.NO_DATE in due -> null
DueWindow.TODAY in due -> today
DueWindow.UPCOMING in due -> today.plusDays(1)
else -> null
},
priority = minPriority,
)
@@ -5,12 +5,16 @@ import kotlin.time.Instant
/** The built-in "smart" lists, computed from due dates rather than membership. */
enum class SmartList { ALL, TODAY, UPCOMING, OVERDUE, NO_DATE, COMPLETED }
/** What a task screen is showing: one real list, or a smart list. */
/** What a task screen is showing: one real list, a smart list, or a tag. */
sealed interface TaskFilter {
data class OfList(val listId: Long) : TaskFilter
/** Several lists shown together, e.g. one widget covering more than one list. */
data class OfLists(val listIds: Set<Long>) : TaskFilter
data class Smart(val list: SmartList) : TaskFilter
/** Every task carrying [tag], open and completed, across all lists. */
data class Tagged(val tag: String) : TaskFilter
/** A [SavedFilter] by id; the repository resolves it to its definition. */
data class Saved(val id: Long) : TaskFilter
}
/**
@@ -20,22 +24,32 @@ sealed interface TaskFilter {
*/
object TaskFiltering {
fun matches(task: Task, filter: TaskFilter, todayStart: Instant, todayEnd: Instant): Boolean =
/** [saved] is the definition behind a [TaskFilter.Saved]; without one it matches nothing. */
fun matches(
task: Task,
filter: TaskFilter,
todayStart: Instant,
todayEnd: Instant,
saved: SavedFilter? = null,
): Boolean =
when (filter) {
is TaskFilter.OfList -> task.listId == filter.listId
is TaskFilter.OfLists -> task.listId in filter.listIds
is TaskFilter.Smart -> matchesSmart(task, filter.list, todayStart, todayEnd)
is TaskFilter.Tagged -> filter.tag in task.tags
is TaskFilter.Saved -> saved?.takeIf { it.id == filter.id }?.matches(task, todayStart, todayEnd) ?: false
}
private fun matchesSmart(task: Task, list: SmartList, todayStart: Instant, todayEnd: Instant): Boolean {
val due = task.due
return when (list) {
/** 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 =
when (list) {
SmartList.COMPLETED -> task.isCompleted
SmartList.ALL -> !task.isClosed
SmartList.NO_DATE -> !task.isClosed && due == null
SmartList.OVERDUE -> !task.isClosed && due != null && due < todayStart
SmartList.TODAY -> !task.isClosed && due != null && due >= todayStart && due < todayEnd
SmartList.UPCOMING -> !task.isClosed && due != null && due >= todayEnd
SmartList.NO_DATE -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.NO_DATE
SmartList.OVERDUE -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.OVERDUE
SmartList.TODAY -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.TODAY
SmartList.UPCOMING -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.UPCOMING
}
}
}
@@ -22,6 +22,7 @@ data class TaskForm(
val reminders: List<Int> = emptyList(),
val location: String? = null,
val url: String? = null,
val tags: List<String> = emptyList(),
/** The series' `RRULE` value; `null` = does not repeat. Ignored for a single-occurrence edit. */
val rrule: String? = null,
) {
@@ -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
* 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. */
fun TaskForm.populatedFields(): Set<TaskFormField> = buildSet {
@@ -56,4 +57,5 @@ fun TaskForm.populatedFields(): Set<TaskFormField> = buildSet {
if (rrule != null) add(TaskFormField.Recurrence)
if (!location.isNullOrBlank()) add(TaskFormField.Location)
if (!url.isNullOrBlank()) add(TaskFormField.Url)
if (tags.isNotEmpty()) add(TaskFormField.Tags)
}
@@ -29,11 +29,11 @@ object TaskSections {
fun sectionOf(task: Task, todayStart: Instant, todayEnd: Instant): TaskSection {
if (task.isCompleted) return TaskSection.COMPLETED
val due = task.due ?: return TaskSection.NO_DATE
return when {
due < todayStart -> TaskSection.OVERDUE
due < todayEnd -> TaskSection.TODAY
else -> TaskSection.UPCOMING
return when (dueWindowOf(task, todayStart, todayEnd)) {
DueWindow.OVERDUE -> TaskSection.OVERDUE
DueWindow.TODAY -> TaskSection.TODAY
DueWindow.UPCOMING -> TaskSection.UPCOMING
DueWindow.NO_DATE -> TaskSection.NO_DATE
}
}
}
@@ -42,6 +42,7 @@ data class ExportTask(
val rdate: String?,
/** `tasks._id` of the parent, for `RELATED-TO;RELTYPE=PARENT`. */
val parentId: Long?,
val tags: List<String> = emptyList(),
)
/** A task list and everything in it, ready to become one `.ics` document. */
@@ -88,6 +88,7 @@ object ICalendarWriter {
task.location?.takeIf { it.isNotBlank() }?.let { property("LOCATION", it) }
// URL is a URI, not TEXT: it must not be escaped like one.
task.url?.takeIf { it.isNotBlank() }?.let { rawProperty("URL", it) }
task.tags.takeIf { it.isNotEmpty() }?.let { rawProperty("CATEGORIES", ICalValues.formatTextList(it)) }
task.start?.let { dateProperty("DTSTART", it, task.isAllDay) }
task.due?.let { dateProperty("DUE", it, task.isAllDay) }
@@ -49,6 +49,37 @@ object ICalValues {
return out.toString()
}
/**
* A multi-valued TEXT property (`CATEGORIES`, §3.8.1.2): split on the
* commas that are not escaped, then unescape each value.
*/
fun parseTextList(value: String): List<String> {
val values = mutableListOf<String>()
val current = StringBuilder()
var i = 0
while (i < value.length) {
val c = value[i]
when {
c == '\\' && i + 1 < value.length -> {
current.append(c).append(value[i + 1])
i += 2
continue
}
c == ',' -> {
values += unescapeText(current.toString())
current.clear()
}
else -> current.append(c)
}
i++
}
values += unescapeText(current.toString())
return values
}
/** The inverse of [parseTextList]. */
fun formatTextList(values: List<String>): String = values.joinToString(",", transform = ::escapeText)
/** How a DATE / DATE-TIME property was written, and whether we can reproduce it. */
sealed interface TimeValue {
/** `VALUE=DATE` — date-only. */
@@ -9,6 +9,7 @@ import androidx.compose.material.icons.rounded.LocationOn
import androidx.compose.material.icons.rounded.Notifications
import androidx.compose.material.icons.rounded.Percent
import androidx.compose.material.icons.rounded.Repeat
import androidx.compose.material.icons.rounded.Sell
import androidx.compose.ui.graphics.vector.ImageVector
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.domain.TaskFormField
@@ -23,6 +24,7 @@ fun TaskFormField.labelRes(): Int = when (this) {
TaskFormField.Reminder -> R.string.edit_reminder_label
TaskFormField.Location -> R.string.edit_location_label
TaskFormField.Url -> R.string.edit_url_label
TaskFormField.Tags -> R.string.edit_tags_label
}
fun TaskFormField.icon(): ImageVector = when (this) {
@@ -34,4 +36,5 @@ fun TaskFormField.icon(): ImageVector = when (this) {
TaskFormField.Reminder -> Icons.Rounded.Notifications
TaskFormField.Location -> Icons.Rounded.LocationOn
TaskFormField.Url -> Icons.Rounded.Link
TaskFormField.Tags -> Icons.Rounded.Sell
}
@@ -0,0 +1,51 @@
package de.jeanlucmakiola.agendula.ui.common
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedSurface
import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.Position
/** A lone grouped card holding a name, for the list and filter editors. */
@Composable
internal fun NameField(
name: String,
placeholder: String,
autoFocus: Boolean,
onNameChange: (String) -> Unit,
onImeAction: () -> Unit,
leading: (@Composable () -> Unit)? = null,
) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(autoFocus) { if (autoFocus) focusRequester.requestFocus() }
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = GroupedListInset)) {
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
leading?.invoke()
InlineTextField(
value = name,
onValueChange = onNameChange,
placeholder = placeholder,
imeAction = ImeAction.Done,
onImeAction = onImeAction,
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
)
}
}
}
@@ -0,0 +1,57 @@
package de.jeanlucmakiola.agendula.ui.common
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
/**
* A pill naming one tag. Solid secondary by default so it reads against any card;
* [muted] is the quieter tonal look for something not yet applied, like a suggestion.
*/
@Composable
fun TagChip(
name: String,
modifier: Modifier = Modifier,
muted: Boolean = false,
icon: ImageVector? = null,
iconContentDescription: String? = null,
onClick: (() -> Unit)? = null,
) {
val container = if (muted) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.secondary
val content = if (muted) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onSecondary
val shape = RoundedCornerShape(50)
val body: @Composable () -> Unit = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(start = 12.dp, end = if (icon != null) 8.dp else 12.dp, top = 6.dp, bottom = 6.dp),
) {
Text(
text = name,
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (icon != null) {
Icon(icon, contentDescription = iconContentDescription, modifier = Modifier.size(16.dp))
}
}
}
if (onClick != null) {
Surface(onClick = onClick, color = container, contentColor = content, shape = shape, modifier = modifier) { body() }
} else {
Surface(color = container, contentColor = content, shape = shape, modifier = modifier) { body() }
}
}
@@ -7,6 +7,8 @@ import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
@@ -42,6 +44,7 @@ import android.net.Uri
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.Link
import androidx.compose.material.icons.rounded.Sell
import androidx.compose.material.icons.rounded.LocationOn
import androidx.compose.material.icons.rounded.Notifications
import androidx.compose.material.icons.rounded.Repeat
@@ -60,6 +63,7 @@ import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope
import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.agendula.ui.common.RecurringScopeDialog
import de.jeanlucmakiola.agendula.ui.common.TagChip
import de.jeanlucmakiola.agendula.ui.common.linkified
import de.jeanlucmakiola.agendula.ui.common.recurrenceText
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel
@@ -97,6 +101,7 @@ import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.domain.readableDescription
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskDetail
import de.jeanlucmakiola.floret.components.revealOnFocus
import de.jeanlucmakiola.floret.components.GroupedSurface
import de.jeanlucmakiola.agendula.ui.common.PriorityChip
import de.jeanlucmakiola.floret.time.formatDate
@@ -240,6 +245,7 @@ fun TaskDetailScreen(
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun DetailBody(
detail: TaskDetail,
@@ -453,6 +459,19 @@ private fun DetailBody(
}
}
if (task.tags.isNotEmpty()) {
add {
DetailCard(icon = Icons.Rounded.Sell, iconContentDescription = stringResource(R.string.edit_tags_label)) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
task.tags.forEach { TagChip(it) }
}
}
}
}
// Description — selectable, with its links tappable.
task.description?.readableDescription()?.takeIf { it.isNotBlank() }?.let { description ->
add {
@@ -606,7 +625,7 @@ private fun AddSubtaskField(onAdd: (String) -> Unit) {
}
val accentOn = MaterialTheme.colorScheme.onPrimaryContainer
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).padding(start = 8.dp, end = 8.dp),
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).revealOnFocus().padding(start = 8.dp, end = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(modifier = Modifier.size(48.dp), contentAlignment = Alignment.Center) {
@@ -1,9 +1,25 @@
package de.jeanlucmakiola.agendula.ui.edit
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.ScrollState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.ui.layout.onPlaced
import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.text.style.TextOverflow
import de.jeanlucmakiola.agendula.ui.common.TagChip
import de.jeanlucmakiola.agendula.ui.common.LocalFirstDayOfWeek
import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat
import androidx.activity.compose.BackHandler
import androidx.compose.material.icons.rounded.Link
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.material.icons.rounded.Sell
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.material.icons.rounded.LocationOn
import androidx.compose.material.icons.rounded.Repeat
import androidx.compose.ui.text.AnnotatedString
@@ -15,6 +31,7 @@ import de.jeanlucmakiola.agendula.ui.common.icon
import de.jeanlucmakiola.agendula.ui.common.labelRes
import de.jeanlucmakiola.agendula.ui.common.recurrenceText
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel
import de.jeanlucmakiola.floret.components.revealOnFocus
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
@@ -153,11 +170,25 @@ fun TaskEditScreen(
val requestBack = { if (state.isDirty && !state.saved) showDiscard = true else onBack() }
BackHandler(enabled = state.isDirty && !state.saved) { showDiscard = true }
// Once the title field scrolls away it rides in the bar, so a long form never
// loses track of which task is being edited.
val scroll = rememberScrollState()
var titleBottom by remember { mutableIntStateOf(Int.MAX_VALUE) }
val titleInBar by remember { derivedStateOf { scroll.value > titleBottom } }
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = {},
title = {
AnimatedVisibility(
visible = titleInBar && state.title.isNotBlank(),
enter = fadeIn() + slideInVertically { it / 2 },
exit = fadeOut() + slideOutVertically { it / 2 },
) {
Text(state.title, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
},
navigationIcon = {
IconButton(onClick = requestBack) {
Icon(Icons.Rounded.Close, contentDescription = stringResource(R.string.close))
@@ -180,6 +211,8 @@ fun TaskEditScreen(
EditContent(
state = state,
viewModel = viewModel,
scroll = scroll,
onTitleBottom = { titleBottom = it },
modifier = Modifier.padding(inner),
)
}
@@ -220,6 +253,8 @@ private enum class PickerTarget { Start, Due }
private fun EditContent(
state: TaskEditUiState,
viewModel: TaskEditViewModel,
scroll: ScrollState,
onTitleBottom: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
val dark = isSystemInDarkTheme()
@@ -250,7 +285,7 @@ private fun EditContent(
// Shrink the scroll viewport by the keyboard so the focused field
// scrolls into view above the IME instead of being hidden.
.imePadding()
.verticalScroll(rememberScrollState())
.verticalScroll(scroll)
.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp),
) {
// Title: borderless headline + accent bar, mirroring the detail screen.
@@ -259,7 +294,9 @@ private fun EditContent(
onValueChange = viewModel::onTitleChange,
placeholder = stringResource(R.string.edit_title_hint),
textStyle = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
modifier = Modifier.focusRequester(titleFocus),
modifier = Modifier
.focusRequester(titleFocus)
.onPlaced { onTitleBottom(it.positionInParent().y.toInt() + it.size.height) },
)
Spacer(Modifier.height(10.dp))
Box(
@@ -572,6 +609,19 @@ private fun EditContent(
}
}
OptionalFormSection(visible = TaskFormField.Tags in state.visibleFields) {
Spacer(Modifier.height(gap))
TagsCard(
tags = state.tags,
draft = state.tagDraft,
suggestions = state.tagSuggestions,
onDraftChange = viewModel::onTagDraftChange,
onCommitDraft = viewModel::commitTagDraft,
onAdd = viewModel::addTag,
onRemove = viewModel::removeTag,
)
}
OptionalFormSection(visible = state.hiddenFields.isNotEmpty()) {
Spacer(Modifier.height(20.dp))
TextButton(
@@ -727,9 +777,9 @@ private fun EditCard(
}
}
if (onClick != null) {
Surface(onClick = onClick, color = color, shape = shape, modifier = Modifier.fillMaxWidth()) { inner() }
Surface(onClick = onClick, color = color, shape = shape, modifier = Modifier.fillMaxWidth().revealOnFocus()) { inner() }
} else {
Surface(color = color, shape = shape, modifier = Modifier.fillMaxWidth()) { inner() }
Surface(color = color, shape = shape, modifier = Modifier.fillMaxWidth().revealOnFocus()) { inner() }
}
}
@@ -1040,6 +1090,77 @@ private fun AddReminderCard(position: Position, label: String, onClick: () -> Un
}
}
/** The input sits beside the icon; the tags and any suggestions run below it. */
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun TagsCard(
tags: List<String>,
draft: String,
suggestions: List<String>,
onDraftChange: (String) -> Unit,
onCommitDraft: () -> Unit,
onAdd: (String) -> Unit,
onRemove: (String) -> Unit,
) {
val query = draft.trim()
val matches = if (query.isEmpty()) {
emptyList()
} else {
suggestions.filter { it.contains(query, ignoreCase = true) && it !in tags }.take(MAX_TAG_SUGGESTIONS)
}
EditCard(
icon = Icons.Rounded.Sell,
iconContentDescription = stringResource(R.string.edit_tags_label),
header = {
InlineTextField(
value = draft,
onValueChange = onDraftChange,
placeholder = stringResource(R.string.edit_tags_hint),
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done,
onImeAction = onCommitDraft,
)
},
) {
if (matches.isNotEmpty()) {
TagFlow(modifier = Modifier.padding(top = 8.dp)) {
matches.forEach { match ->
TagChip(
name = match,
muted = true,
icon = Icons.Rounded.Add,
onClick = { onAdd(match) },
)
}
}
}
if (tags.isNotEmpty()) {
TagFlow(modifier = Modifier.padding(top = 8.dp)) {
tags.forEach { tag ->
TagChip(
name = tag,
icon = Icons.Rounded.Close,
iconContentDescription = stringResource(R.string.edit_tag_remove, tag),
onClick = { onRemove(tag) },
)
}
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun TagFlow(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
FlowRow(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) { content() }
}
private const val MAX_TAG_SUGGESTIONS = 8
/** Picks one hidden optional section to add to the form. */
@Composable
private fun FieldPickerDialog(
@@ -8,15 +8,20 @@ import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
import de.jeanlucmakiola.agendula.data.tasks.TaskConflictException
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskFormError
import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.agendula.domain.allDayInstantOf
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.populatedFields
import de.jeanlucmakiola.agendula.domain.htmlToPlainText
import de.jeanlucmakiola.agendula.domain.looksLikeHtml
import de.jeanlucmakiola.agendula.domain.newTaskPreset
import de.jeanlucmakiola.agendula.domain.normalizeTags
import de.jeanlucmakiola.agendula.domain.tagsInUse
import de.jeanlucmakiola.agendula.domain.rebasedForAllDay
import de.jeanlucmakiola.agendula.domain.readableDescription
import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope
@@ -25,6 +30,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import java.time.LocalDate
import javax.inject.Inject
import kotlin.time.Clock
import kotlin.time.Instant
@@ -51,6 +57,11 @@ data class TaskEditUiState(
val reminders: List<Int> = emptyList(),
val location: String = "",
val url: String = "",
val tags: List<String> = emptyList(),
/** What is typed in the tag field and not added yet. Saving still takes it. */
val tagDraft: String = "",
/** Every tag already in use, for completing what the user types. */
val tagSuggestions: List<String> = emptyList(),
val rrule: String? = null,
/** Editing one occurrence of a series: saving asks how far the edit reaches. */
val isOccurrence: Boolean = false,
@@ -114,7 +125,13 @@ class TaskEditViewModel @Inject constructor(
private var defaultFields: Set<TaskFormField> = emptySet()
/** Start a fresh task, optionally pre-selecting a list / parent. */
fun bindNew(presetListId: Long? = null, parentId: Long? = null, initialTitle: String? = null) {
fun bindNew(
presetListId: Long? = null,
parentId: Long? = null,
initialTitle: String? = null,
presetTag: String? = null,
presetFilterId: Long? = null,
) {
if (bound) return
bound = true
editingTaskId = null
@@ -123,12 +140,16 @@ class TaskEditViewModel @Inject constructor(
val settings = settingsPrefs.settings.first()
defaultFields = settings.defaultEditFields
val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
val preset = presetFilterId?.let { settingsPrefs.savedFilter(it).first() }
?.newTaskPreset(LocalDate.now())
// A read-only or vanished default — or a widget's preset list, if it was
// since deleted or turned read-only — would give the task a list it
// cannot be saved to.
val writable = lists.filter { it.acceptsWrites }
val filterLists = preset?.listIds.orEmpty()
val defaultList = presetListId?.takeIf { id -> writable.any { it.id == id } }
?: settings.defaultListId?.takeIf { id -> writable.any { it.id == id } }
?: settings.defaultListId?.takeIf { id -> writable.any { it.id == id } && (filterLists.isEmpty() || id in filterLists) }
?: writable.firstOrNull { it.id in filterLists }?.id
?: writable.firstOrNull { !it.isLocal }?.id
?: writable.firstOrNull()?.id
_state.value = withFields(
@@ -137,11 +158,22 @@ class TaskEditViewModel @Inject constructor(
isNew = true,
autofocusTitle = settings.autofocusTitle,
listId = defaultList,
start = if (settings.defaultStartToday) Clock.System.now() else null,
// A preset due is all-day, so a default start has to be too, or
// today's start would land after a due of today.
start = when {
!settings.defaultStartToday -> null
preset?.dueDate != null -> allDayInstantOf(LocalDate.now())
else -> Clock.System.now()
},
// The same all-day value picking the date in the date field gives.
due = preset?.dueDate?.let(::allDayInstantOf),
isAllDay = preset?.dueDate != null,
priority = preset?.priority ?: Priority.NONE,
parentId = parentId,
lists = lists,
parentCandidates = loadParents(defaultList, selfId = null),
title = initialTitle.orEmpty(),
tags = (listOfNotNull(presetTag) + preset?.tags.orEmpty()).distinct(),
),
).also { baseline = it.toForm().copy(title = "") }
}
@@ -179,6 +211,7 @@ class TaskEditViewModel @Inject constructor(
reminders = repository.remindersFor(taskId).filter { !it.fromStart }.map { it.minutesBefore },
location = task.location.orEmpty(),
url = task.url.orEmpty(),
tags = task.tags,
rrule = task.recurrenceRule,
originalRule = task.recurrenceRule,
isOccurrence = task.isOccurrence,
@@ -202,6 +235,18 @@ class TaskEditViewModel @Inject constructor(
return tasks.filter { !it.isClosed && !it.isSubtask && it.taskId != selfId }
}
private var suggestionsRequested = false
/** Tags on open tasks, read once the user starts typing one rather than on every bind. */
private fun loadTagSuggestions() {
if (suggestionsRequested) return
suggestionsRequested = true
viewModelScope.launch {
val tasks = runCatching { repository.tasks(TaskFilter.Smart(SmartList.ALL)).first() }.getOrElse { emptyList() }
_state.value = _state.value.copy(tagSuggestions = tagsInUse(tasks))
}
}
/** Reveal an optional section (one tap from the "More fields" picker). */
fun revealField(field: TaskFormField) = update {
val visible = it.visibleFields + field
@@ -257,6 +302,18 @@ class TaskEditViewModel @Inject constructor(
fun removeReminder(minutesBeforeDue: Int) = update { it.copy(reminders = it.reminders - minutesBeforeDue) }
fun onLocationChange(value: String) = update { it.copy(location = value) }
fun onUrlChange(value: String) = update { it.copy(url = value) }
fun addTag(name: String) = update { it.copy(tags = normalizeTags(it.tags + name), tagDraft = "") }
/** A typed comma adds what came before it. */
fun onTagDraftChange(value: String) {
loadTagSuggestions()
update {
if (',' in value) it.copy(tags = it.withDraft(value), tagDraft = "") else it.copy(tagDraft = value)
}
}
fun commitTagDraft() = update { it.copy(tags = it.withDraft(it.tagDraft), tagDraft = "") }
fun removeTag(name: String) = update { it.copy(tags = it.tags - name) }
fun onRecurrenceChange(rrule: String?) = update { it.copy(rrule = rrule, errors = emptySet()) }
/** Dismiss the conflict prompt without saving (the user keeps editing). */
@@ -339,5 +396,8 @@ private fun TaskEditUiState.toForm(): TaskForm = TaskForm(
reminders = reminders,
location = location.ifBlank { null },
url = url.ifBlank { null },
tags = withDraft(tagDraft),
rrule = rrule,
)
private fun TaskEditUiState.withDraft(draft: String): List<String> = normalizeTags(tags + draft.split(','))
@@ -0,0 +1,259 @@
package de.jeanlucmakiola.agendula.ui.filters
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ChevronRight
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.domain.DueWindow
import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.agendula.domain.SavedFilter
import de.jeanlucmakiola.agendula.domain.TagMatch
import de.jeanlucmakiola.agendula.ui.common.ListColorChip
import de.jeanlucmakiola.agendula.ui.common.NameField
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.positionOf
/**
* Creates or edits a [SavedFilter]: a name, then due windows, priority, lists and
* tags. Each multi-choice section leads with an "any" row that is selected while
* nothing in it is, so an untouched section visibly doesn't narrow.
*/
@Composable
fun FilterEditScreen(
viewModel: FilterEditViewModel,
isNew: Boolean,
onSaved: (Long) -> Unit,
onDeleted: () -> Unit,
onBack: () -> Unit,
) {
val draft by viewModel.draft.collectAsStateWithLifecycle()
val lists by viewModel.lists.collectAsStateWithLifecycle()
val tagsInUse by viewModel.tags.collectAsStateWithLifecycle()
val filter = draft
var choosingPriority by rememberSaveable { mutableStateOf(false) }
var confirmDelete by rememberSaveable { mutableStateOf(false) }
val save = { viewModel.save(onSaved) }
CollapsingScaffold(
title = stringResource(if (isNew) R.string.filter_new_title else R.string.filter_edit_title),
onBack = onBack,
largeTopBar = false,
actions = {
Button(
onClick = save,
enabled = filter?.name?.isNotBlank() == true,
modifier = Modifier.padding(end = 12.dp),
) { Text(stringResource(R.string.save)) }
},
) {
if (filter == null) return@CollapsingScaffold
NameField(
name = filter.name,
placeholder = stringResource(R.string.filter_name_hint),
autoFocus = isNew,
onNameChange = { name -> viewModel.update { it.copy(name = name) } },
onImeAction = save,
)
GroupedSectionHeader(stringResource(R.string.filter_section_due))
val windows = DueWindow.entries
ChoiceRow(
title = stringResource(R.string.filter_any_due),
position = positionOf(0, windows.size + 1),
selected = filter.due.isEmpty(),
onClick = { viewModel.update { it.copy(due = emptySet()) } },
)
windows.forEachIndexed { index, window ->
ChoiceRow(
title = dueWindowLabel(window),
position = positionOf(index + 1, windows.size + 1),
selected = window in filter.due,
onClick = { viewModel.update { it.copy(due = it.due.toggle(window)) } },
)
}
GroupedSectionHeader(stringResource(R.string.filter_section_priority))
GroupedRow(
title = minPriorityLabel(filter.minPriority),
position = Position.Alone,
trailing = { Icon(Icons.Rounded.ChevronRight, contentDescription = null) },
onClick = { choosingPriority = true },
)
if (lists.isNotEmpty()) {
GroupedSectionHeader(stringResource(R.string.filter_section_lists))
val showAccount = lists.mapTo(HashSet()) { it.accountName }.size > 1
ChoiceRow(
title = stringResource(R.string.filter_any_list),
position = positionOf(0, lists.size + 1),
selected = filter.anyList,
onClick = { viewModel.update { it.copy(listIds = emptySet(), listsElsewhere = false) } },
)
lists.forEachIndexed { index, list ->
ChoiceRow(
title = list.name,
summary = list.accountName.takeIf { showAccount },
position = positionOf(index + 1, lists.size + 1),
selected = list.id in filter.listIds,
leading = { ListColorChip(list.color) },
onClick = { viewModel.update { it.copy(listIds = it.listIds.toggle(list.id)) } },
)
}
}
// A tag no open task carries any more stays listed while the filter uses
// it, so it can still be unticked.
val tags = remember(tagsInUse, filter.tags) {
(tagsInUse + filter.tags).distinct().sortedWith(String.CASE_INSENSITIVE_ORDER)
}
if (tags.isNotEmpty()) {
GroupedSectionHeader(stringResource(R.string.filter_section_tags))
ChoiceRow(
title = stringResource(R.string.filter_any_tag),
position = positionOf(0, tags.size + 1),
selected = filter.tags.isEmpty(),
onClick = { viewModel.update { it.copy(tags = emptySet()) } },
)
tags.forEachIndexed { index, tag ->
ChoiceRow(
title = tag,
position = positionOf(index + 1, tags.size + 1),
selected = tag in filter.tags,
onClick = { viewModel.update { it.copy(tags = it.tags.toggle(tag)) } },
)
}
if (filter.tags.size > 1) {
Spacer(Modifier.height(8.dp))
GroupedRow(
title = stringResource(R.string.filter_match_all_tags),
summary = stringResource(
if (filter.tagMatch == TagMatch.ALL) R.string.filter_match_all_on else R.string.filter_match_all_off,
),
position = Position.Alone,
trailing = {
Switch(
checked = filter.tagMatch == TagMatch.ALL,
onCheckedChange = { all ->
viewModel.update { it.copy(tagMatch = if (all) TagMatch.ALL else TagMatch.ANY) }
},
)
},
onClick = {
viewModel.update { it.copy(tagMatch = if (it.tagMatch == TagMatch.ALL) TagMatch.ANY else TagMatch.ALL) }
},
)
}
}
if (filter.id > 0) {
Spacer(Modifier.height(24.dp))
GroupedRow(
title = stringResource(R.string.filter_delete),
position = Position.Alone,
contentColor = MaterialTheme.colorScheme.error,
leading = { Icon(Icons.Rounded.Delete, contentDescription = null) },
onClick = { confirmDelete = true },
)
}
Spacer(Modifier.height(24.dp))
}
if (choosingPriority && filter != null) {
OptionPicker(
title = stringResource(R.string.filter_section_priority),
options = Priority.entries,
selected = filter.minPriority,
label = { minPriorityLabel(it) },
onSelect = { priority -> viewModel.update { it.copy(minPriority = priority) } },
onDismiss = { choosingPriority = false },
)
}
if (confirmDelete && filter != null) {
AlertDialog(
onDismissRequest = { confirmDelete = false },
title = { Text(stringResource(R.string.filter_delete_title, filter.name)) },
text = { Text(stringResource(R.string.filter_delete_body)) },
confirmButton = {
TextButton(onClick = { confirmDelete = false; viewModel.delete(onDeleted) }) {
Text(stringResource(R.string.filter_delete_confirm))
}
},
dismissButton = {
TextButton(onClick = { confirmDelete = false }) { Text(stringResource(R.string.dialog_cancel)) }
},
)
}
}
@Composable
private fun ChoiceRow(
title: String,
position: Position,
selected: Boolean,
onClick: () -> Unit,
summary: String? = null,
leading: @Composable (() -> Unit)? = null,
) {
GroupedRow(
title = title,
summary = summary,
position = position,
selected = selected,
leading = leading,
trailing = if (selected) {
{ SelectedCheck() }
} else {
null
},
onClick = onClick,
)
}
private fun <T> Set<T>.toggle(item: T): Set<T> = if (item in this) this - item else this + item
@Composable
private fun dueWindowLabel(window: DueWindow): String = stringResource(
when (window) {
DueWindow.OVERDUE -> R.string.smart_overdue
DueWindow.TODAY -> R.string.smart_today
DueWindow.UPCOMING -> R.string.smart_upcoming
DueWindow.NO_DATE -> R.string.smart_no_date
},
)
@Composable
internal fun minPriorityLabel(priority: Priority): String = stringResource(
when (priority) {
Priority.NONE -> R.string.filter_priority_any
Priority.LOW -> R.string.filter_priority_low
Priority.MEDIUM -> R.string.filter_priority_medium
Priority.HIGH -> R.string.filter_priority_high
},
)
@@ -0,0 +1,91 @@
package de.jeanlucmakiola.agendula.ui.filters
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.domain.SavedFilter
import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.tagsInUse
import de.jeanlucmakiola.agendula.widget.TaskWidgetUpdater
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
/** Holds the filter being created or edited until it's saved. Call [bind] first. */
@HiltViewModel
class FilterEditViewModel @Inject constructor(
private val settingsPrefs: SettingsPrefs,
private val widgetUpdater: TaskWidgetUpdater,
private val repository: TasksRepository,
) : ViewModel() {
private val _draft = MutableStateFlow<SavedFilter?>(null)
/** `null` until [bind] has loaded the filter. An id of 0 is a new one. */
val draft: StateFlow<SavedFilter?> = _draft.asStateFlow()
val lists: StateFlow<List<TaskList>> = repository.taskLists()
.catch { emit(emptyList()) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
/** Every tag on an open task. */
val tags: StateFlow<List<String>> = repository.tasks(TaskFilter.Smart(SmartList.ALL))
.map { tagsInUse(it) }
.catch { emit(emptyList()) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
private var bound = false
private var saving = false
fun bind(filterId: Long?) {
if (bound) return
bound = true
if (filterId == null) {
_draft.value = SavedFilter(id = 0, name = "")
return
}
viewModelScope.launch {
val saved = settingsPrefs.savedFilter(filterId).first()
// Show it as it matches: lists deleted since drop out, as they do in the repository.
val existing = runCatching { repository.taskLists().first().mapTo(HashSet()) { it.id } }.getOrNull()
_draft.value = saved?.let { if (existing == null) it else it.withListsIn(existing) }
?: SavedFilter(id = 0, name = "")
}
}
fun update(transform: (SavedFilter) -> SavedFilter) {
_draft.value = _draft.value?.let(transform)
}
fun save(onSaved: (Long) -> Unit) {
val filter = _draft.value ?: return
if (filter.name.isBlank() || saving) return
saving = true
viewModelScope.launch {
val id = settingsPrefs.saveFilter(filter.copy(name = filter.name.trim()))
// A widget showing this filter doesn't watch the prefs.
widgetUpdater.requestRefresh()
onSaved(id)
}
}
fun delete(onDeleted: () -> Unit) {
val id = _draft.value?.id?.takeIf { it > 0 } ?: return
viewModelScope.launch {
settingsPrefs.deleteFilter(id)
widgetUpdater.requestRefresh()
onDeleted()
}
}
}
@@ -30,7 +30,6 @@ import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@@ -40,25 +39,22 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.ui.common.DefaultListColor
import de.jeanlucmakiola.agendula.ui.common.ListColorChip
import de.jeanlucmakiola.agendula.ui.common.ListPalette
import de.jeanlucmakiola.agendula.ui.common.NameField
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSurface
import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.pastelize
@@ -258,27 +254,14 @@ internal fun ListNameField(
autoFocus: Boolean,
onNameChange: (String) -> Unit,
onImeAction: () -> Unit,
) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(autoFocus) { if (autoFocus) focusRequester.requestFocus() }
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) {
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
ListColorChip(color)
InlineTextField(
value = name,
onValueChange = onNameChange,
placeholder = stringResource(R.string.list_name_hint),
imeAction = ImeAction.Done,
onImeAction = onImeAction,
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
)
}
}
}
) = NameField(
name = name,
placeholder = stringResource(R.string.list_name_hint),
autoFocus = autoFocus,
onNameChange = onNameChange,
onImeAction = onImeAction,
leading = { ListColorChip(color) },
)
/**
* The palette as two rows of round swatches; the chosen one carries a check.
@@ -3,6 +3,8 @@ package de.jeanlucmakiola.agendula.ui.lists
import de.jeanlucmakiola.floret.components.ReorderableColumn
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.DragHandle
import androidx.compose.material.icons.rounded.FilterList
import androidx.compose.material.icons.rounded.Sell
import androidx.compose.material.icons.rounded.SwapVert
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
@@ -30,6 +32,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
@@ -86,6 +89,7 @@ import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.ui.common.ActionShapes
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SnackChip
import de.jeanlucmakiola.floret.components.SnackChipHeight
import de.jeanlucmakiola.floret.components.SnackChipMargin
@@ -114,6 +118,7 @@ fun ListsScreen(
onNewTask: () -> Unit,
onOpenSettings: () -> Unit,
modifier: Modifier = Modifier,
onNewFilter: () -> Unit = {},
viewModel: ListsViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
@@ -179,6 +184,7 @@ fun ListsScreen(
onOpenFilter = onOpenFilter,
onOpenTask = onOpenTask,
onNewList = { newList = true },
onNewFilter = onNewFilter,
onReorder = viewModel::reorderLists,
topPadding = 0.dp,
bottomPadding = inner.calculateBottomPadding() + 96.dp,
@@ -252,6 +258,7 @@ private fun ListsContent(
onOpenFilter: (TaskFilter) -> Unit,
onOpenTask: (Task) -> Unit,
onNewList: () -> Unit,
onNewFilter: () -> Unit,
onReorder: (List<Long>) -> Unit,
topPadding: androidx.compose.ui.unit.Dp,
bottomPadding: androidx.compose.ui.unit.Dp,
@@ -370,6 +377,90 @@ private fun ListsContent(
}
}
}
if (state.tags.isNotEmpty()) {
item(key = "tags-header") { SectionHeader(stringResource(R.string.tags_header)) }
itemsIndexed(state.tags, key = { _, t -> "tag-${t.tag}" }) { index, overview ->
GroupedRow(
title = overview.tag,
position = positionOf(index, state.tags.size),
leading = {
Icon(
Icons.Rounded.Sell,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
trailing = {
Text(
overview.openCount.toString(),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
onClick = { onOpenFilter(TaskFilter.Tagged(overview.tag)) },
)
}
}
if (state.groups.isNotEmpty()) {
filtersSection(state.filters, onOpenFilter, onNewFilter)
}
}
}
/** The saved filters, or a single row offering the first one. */
private fun LazyListScope.filtersSection(
filters: List<FilterOverview>,
onOpenFilter: (TaskFilter) -> Unit,
onNewFilter: () -> Unit,
) {
item(key = "filters-header") {
SectionHeader(
text = stringResource(R.string.filters_header),
action = if (filters.isEmpty()) {
null
} else {
{
IconButton(onClick = onNewFilter) {
Icon(Icons.Rounded.Add, contentDescription = stringResource(R.string.filter_new_title))
}
}
},
)
}
if (filters.isEmpty()) {
item(key = "filters-empty") {
GroupedRow(
title = stringResource(R.string.filter_new_title),
summary = stringResource(R.string.filters_empty_hint),
position = Position.Alone,
leading = { Icon(Icons.Rounded.Add, contentDescription = null) },
onClick = onNewFilter,
)
}
return
}
itemsIndexed(filters, key = { _, f -> "filter-${f.filter.id}" }) { index, overview ->
GroupedRow(
title = overview.filter.name,
position = positionOf(index, filters.size),
leading = {
Icon(
Icons.Rounded.FilterList,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
trailing = {
Text(
overview.openCount.toString(),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
onClick = { onOpenFilter(TaskFilter.Saved(overview.filter.id)) },
)
}
}
@@ -8,11 +8,13 @@ import de.jeanlucmakiola.agendula.data.sync.RemoteLists
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
import de.jeanlucmakiola.floret.time.DayWindow
import de.jeanlucmakiola.agendula.domain.SavedFilter
import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskFiltering
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.tagsInUse
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -73,6 +75,8 @@ internal fun RemoteLists.Outcome.asFailure(local: ListWriteFailure): ListWriteFa
data class ListOverview(val list: TaskList, val openCount: Int)
data class AccountGroup(val accountName: String, val lists: List<ListOverview>)
data class SmartCount(val smart: SmartList, val count: Int)
data class TagOverview(val tag: String, val openCount: Int)
data class FilterOverview(val filter: SavedFilter, val openCount: Int)
sealed interface ListsUiState {
data object Loading : ListsUiState
@@ -87,6 +91,10 @@ sealed interface ListsUiState {
val upcoming: List<Task>,
/** Every task (open and completed) — the corpus the home search filters. */
val allTasks: List<Task>,
/** Each tag on an open task, alphabetically. */
val tags: List<TagOverview> = emptyList(),
/** The saved filters, oldest first. */
val filters: List<FilterOverview> = emptyList(),
) : ListsUiState
}
@@ -125,8 +133,9 @@ class ListsViewModel @Inject constructor(
// ones already ticked off to show "x of y done", so read them too.
repository.tasks(TaskFilter.Smart(SmartList.COMPLETED)),
settingsPrefs.settings.map { it.hiddenFromSmartLists }.distinctUntilChanged(),
) { lists, openTasks, completedTasks, hidden ->
buildContent(lists, openTasks, completedTasks, hidden) as ListsUiState
settingsPrefs.savedFilters,
) { lists, openTasks, completedTasks, hidden, filters ->
buildContent(lists, openTasks, completedTasks, hidden, filters) as ListsUiState
}.recoveringFromProviderFailure { ListsUiState.Failure }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListsUiState.Loading)
@@ -135,6 +144,7 @@ class ListsViewModel @Inject constructor(
openTasks: List<Task>,
completedTasks: List<Task>,
hidden: Set<Long>,
savedFilters: List<SavedFilter>,
): ListsUiState.Content {
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
// Count only top-level tasks: a subtask is represented by its parent (and
@@ -177,13 +187,29 @@ class ListsViewModel @Inject constructor(
)
}
.sortedBy { it.accountName.lowercase() }
// Counts the open rows the tag's own screen shows: a subtask folds under a
// parent carrying the same tag, and stands on its own otherwise.
val allTasks = openTasks + completedTasks
val tags = tagsInUse(openTasks).map { tag ->
val tagged = allTasks.filter { tag in it.tags }
val ids = tagged.mapTo(HashSet()) { it.taskId }
TagOverview(tag, tagged.count { !it.isClosed && (!it.isSubtask || it.parentId !in ids) })
}
// Same folding as the tags: a subtask counts only when its parent isn't in the filter too.
val filters = savedFilters.map { filter ->
val matching = openTasks.filter { filter.matches(it, todayStart, todayEnd) }
val ids = matching.mapTo(HashSet()) { it.taskId }
FilterOverview(filter, matching.count { !it.isSubtask || it.parentId !in ids })
}
return ListsUiState.Content(
smartCounts = smartCounts,
filters = filters,
groups = groups,
todayDone = completedDueToday,
todayTotal = todayTotal,
upcoming = upcoming,
allTasks = openTasks + completedTasks,
allTasks = allTasks,
tags = tags,
)
}
@@ -20,6 +20,8 @@ import de.jeanlucmakiola.agendula.ui.detail.TaskDetailScreen
import de.jeanlucmakiola.agendula.ui.detail.TaskDetailViewModel
import de.jeanlucmakiola.agendula.ui.edit.TaskEditScreen
import de.jeanlucmakiola.agendula.ui.edit.TaskEditViewModel
import de.jeanlucmakiola.agendula.ui.filters.FilterEditScreen
import de.jeanlucmakiola.agendula.ui.filters.FilterEditViewModel
import de.jeanlucmakiola.agendula.ui.imports.ImportScreen
import de.jeanlucmakiola.agendula.ui.lists.ListsScreen
import de.jeanlucmakiola.agendula.ui.settings.SettingsScreen
@@ -60,6 +62,9 @@ fun AgendulaNavHost(
is NavRequest.OpenList -> nav.navigate(Dest.TaskList.build(TaskFilter.OfList(navRequest.listId))) {
launchSingleTop = true
}
is NavRequest.OpenFilter -> nav.navigate(Dest.TaskList.build(TaskFilter.Saved(navRequest.filterId))) {
launchSingleTop = true
}
is NavRequest.Import -> nav.navigate(Dest.Import.build(navRequest.uri))
}
onNavRequestConsumed()
@@ -85,6 +90,7 @@ fun AgendulaNavHost(
onOpenFilter = { filter -> nav.navigate(Dest.TaskList.build(filter)) },
onOpenTask = { task -> nav.navigate(task.detailRoute()) },
onNewTask = { nav.navigate(Dest.TaskEdit.buildNew()) },
onNewFilter = { nav.navigate(Dest.FilterEdit.build()) },
onOpenSettings = { nav.navigate(Dest.SETTINGS) },
)
}
@@ -114,6 +120,8 @@ fun AgendulaNavHost(
val filter = Dest.TaskList.filterOf(
smart = args?.getString(Dest.TaskList.ARG_SMART),
listId = args?.getLong(Dest.TaskList.ARG_LIST_ID) ?: Dest.NO_ID,
tag = args?.getString(Dest.TaskList.ARG_TAG),
filterId = args?.getLong(Dest.TaskList.ARG_FILTER_ID) ?: Dest.NO_ID,
)
val vm: TaskListViewModel = hiltViewModel()
LaunchedEffect(filter) { vm.bind(filter) }
@@ -124,9 +132,15 @@ fun AgendulaNavHost(
viewModel = vm,
onOpenTask = onOpenTask,
onNewTask = {
val presetListId = (filter as? TaskFilter.OfList)?.listId
nav.navigate(Dest.TaskEdit.buildNew(presetListId = presetListId))
nav.navigate(
Dest.TaskEdit.buildNew(
presetListId = (filter as? TaskFilter.OfList)?.listId,
tag = (filter as? TaskFilter.Tagged)?.tag,
filterId = (filter as? TaskFilter.Saved)?.id,
),
)
},
onEditFilter = { id -> nav.navigate(Dest.FilterEdit.build(id)) },
onBack = { nav.popBackStack() },
)
}
@@ -139,6 +153,32 @@ fun AgendulaNavHost(
}
}
composable(Dest.FilterEdit.route, arguments = Dest.FilterEdit.arguments) { entry ->
val filterId = entry.arguments?.getLong(Dest.FilterEdit.ARG_FILTER_ID)?.takeIf { it != Dest.NO_ID }
val vm: FilterEditViewModel = hiltViewModel()
LaunchedEffect(filterId) { vm.bind(filterId) }
ReadableWidth {
FilterEditScreen(
viewModel = vm,
isNew = filterId == null,
onSaved = { id ->
// A different id means the edited filter was gone and a new one was made.
if (id != filterId) {
// A new filter opens straight away, with the editor off the stack.
nav.navigate(Dest.TaskList.build(TaskFilter.Saved(id))) {
popUpTo(Dest.FilterEdit.route) { inclusive = true }
}
} else {
nav.popBackStack()
}
},
onDeleted = { nav.popBackStack(Dest.LISTS, inclusive = false) },
onBack = { nav.popBackStack() },
)
}
}
composable(Dest.TaskDetail.route, arguments = Dest.TaskDetail.arguments) { entry ->
val taskId = entry.arguments?.getLong(Dest.TaskDetail.ARG_TASK_ID) ?: Dest.NO_ID
val occurrence = entry.arguments?.getLong(Dest.TaskDetail.ARG_OCC)?.takeIf { it != Dest.NO_ID }
@@ -166,6 +206,8 @@ fun AgendulaNavHost(
val parentId = args?.getLong(Dest.TaskEdit.ARG_PARENT_ID) ?: Dest.NO_ID
val occurrence = args?.getLong(Dest.TaskEdit.ARG_OCCURRENCE)?.takeIf { it != Dest.NO_ID }
val title = args?.getString(Dest.TaskEdit.ARG_TITLE)
val tag = args?.getString(Dest.TaskEdit.ARG_TAG)
val filterId = args?.getLong(Dest.TaskEdit.ARG_FILTER_ID)?.takeIf { it != Dest.NO_ID }
val vm: TaskEditViewModel = hiltViewModel()
LaunchedEffect(taskId, presetListId, parentId) {
if (taskId != Dest.NO_ID) {
@@ -175,6 +217,8 @@ fun AgendulaNavHost(
presetListId = presetListId.takeIf { it != Dest.NO_ID },
parentId = parentId.takeIf { it != Dest.NO_ID },
initialTitle = title,
presetTag = tag,
presetFilterId = filterId,
)
}
}
@@ -53,30 +53,51 @@ object Dest {
}
/**
* One [TaskFilter]'s tasks. Carries *either* `listId` (a real list) *or*
* `smart` (a [SmartList] name); whichever is set decides the filter.
* One [TaskFilter]'s tasks. Carries one of `listId` (a real list), `smart`
* (a [SmartList] name), `tag` or `filterId` (a saved filter); whichever is
* set decides the filter.
*/
object TaskList {
const val ARG_LIST_ID = "listId"
const val ARG_SMART = "smart"
const val route = "taskList?$ARG_LIST_ID={$ARG_LIST_ID}&$ARG_SMART={$ARG_SMART}"
const val ARG_TAG = "tag"
const val ARG_FILTER_ID = "filterId"
const val route = "taskList?$ARG_LIST_ID={$ARG_LIST_ID}&$ARG_SMART={$ARG_SMART}&$ARG_TAG={$ARG_TAG}" +
"&$ARG_FILTER_ID={$ARG_FILTER_ID}"
val arguments = listOf(
navArgument(ARG_LIST_ID) { type = NavType.LongType; defaultValue = NO_ID },
navArgument(ARG_SMART) { type = NavType.StringType; nullable = true; defaultValue = null },
navArgument(ARG_TAG) { type = NavType.StringType; nullable = true; defaultValue = null },
navArgument(ARG_FILTER_ID) { type = NavType.LongType; defaultValue = NO_ID },
)
fun build(filter: TaskFilter): String = when (filter) {
is TaskFilter.OfList -> "taskList?$ARG_LIST_ID=${filter.listId}"
is TaskFilter.Smart -> "taskList?$ARG_SMART=${filter.list.name}"
is TaskFilter.Tagged -> "taskList?$ARG_TAG=${android.net.Uri.encode(filter.tag)}"
is TaskFilter.Saved -> "taskList?$ARG_FILTER_ID=${filter.id}"
// Several lists at once only exists for the widget, which never routes here.
is TaskFilter.OfLists -> error("OfLists has no task-list screen")
}
/** Reconstruct the filter from a destination's arguments. */
fun filterOf(smart: String?, listId: Long): TaskFilter =
if (smart != null) TaskFilter.Smart(SmartList.valueOf(smart))
else TaskFilter.OfList(listId)
fun filterOf(smart: String?, listId: Long, tag: String? = null, filterId: Long = NO_ID): TaskFilter = when {
smart != null -> TaskFilter.Smart(SmartList.valueOf(smart))
tag != null -> TaskFilter.Tagged(tag)
filterId != NO_ID -> TaskFilter.Saved(filterId)
else -> TaskFilter.OfList(listId)
}
}
/** Creates a saved filter, or edits the one `filterId` names. */
object FilterEdit {
const val ARG_FILTER_ID = "filterId"
const val route = "filterEdit?$ARG_FILTER_ID={$ARG_FILTER_ID}"
val arguments = listOf(navArgument(ARG_FILTER_ID) { type = NavType.LongType; defaultValue = NO_ID })
fun build(filterId: Long? = null): String =
if (filterId == null) "filterEdit" else "filterEdit?$ARG_FILTER_ID=$filterId"
}
/** A single task's detail; `occ` (epoch millis) picks one occurrence of a series. */
@@ -97,7 +118,8 @@ object Dest {
/**
* The create / edit form. `taskId` set ([NO_ID] otherwise) ⇒ edit; for a new
* task, `presetListId` / `parentId` optionally seed the form.
* task, `presetListId` / `parentId` optionally seed the form, and `filterId`
* fills it so the task lands in that saved filter.
*/
object TaskEdit {
const val ARG_TASK_ID = "taskId"
@@ -105,12 +127,16 @@ object Dest {
const val ARG_PARENT_ID = "parentId"
const val ARG_OCCURRENCE = "occ"
const val ARG_TITLE = "title"
const val ARG_TAG = "tag"
const val ARG_FILTER_ID = "filterId"
const val route =
"taskEdit?$ARG_TASK_ID={$ARG_TASK_ID}" +
"&$ARG_PRESET_LIST_ID={$ARG_PRESET_LIST_ID}" +
"&$ARG_PARENT_ID={$ARG_PARENT_ID}" +
"&$ARG_OCCURRENCE={$ARG_OCCURRENCE}" +
"&$ARG_TITLE={$ARG_TITLE}"
"&$ARG_TITLE={$ARG_TITLE}" +
"&$ARG_TAG={$ARG_TAG}" +
"&$ARG_FILTER_ID={$ARG_FILTER_ID}"
val arguments = listOf(
navArgument(ARG_TASK_ID) { type = NavType.LongType; defaultValue = NO_ID },
@@ -118,17 +144,27 @@ object Dest {
navArgument(ARG_PARENT_ID) { type = NavType.LongType; defaultValue = NO_ID },
navArgument(ARG_OCCURRENCE) { type = NavType.LongType; defaultValue = NO_ID },
navArgument(ARG_TITLE) { type = NavType.StringType; nullable = true; defaultValue = null },
navArgument(ARG_TAG) { type = NavType.StringType; nullable = true; defaultValue = null },
navArgument(ARG_FILTER_ID) { type = NavType.LongType; defaultValue = NO_ID },
)
fun buildEdit(taskId: Long, occurrenceStart: Long? = null): String =
if (occurrenceStart == null) "taskEdit?$ARG_TASK_ID=$taskId"
else "taskEdit?$ARG_TASK_ID=$taskId&$ARG_OCCURRENCE=$occurrenceStart"
fun buildNew(presetListId: Long? = null, parentId: Long? = null, title: String? = null): String {
fun buildNew(
presetListId: Long? = null,
parentId: Long? = null,
title: String? = null,
tag: String? = null,
filterId: Long? = null,
): String {
val params = buildList {
if (presetListId != null) add("$ARG_PRESET_LIST_ID=$presetListId")
if (parentId != null) add("$ARG_PARENT_ID=$parentId")
if (!title.isNullOrBlank()) add("$ARG_TITLE=${android.net.Uri.encode(title)}")
if (!tag.isNullOrBlank()) add("$ARG_TAG=${android.net.Uri.encode(tag)}")
if (filterId != null) add("$ARG_FILTER_ID=$filterId")
}
return if (params.isEmpty()) "taskEdit" else "taskEdit?${params.joinToString("&")}"
}
@@ -30,6 +30,9 @@ sealed interface NavRequest {
/** One real list — a widget header. */
data class OpenList(val listId: Long) : NavRequest
/** One saved filter — a widget header. */
data class OpenFilter(val filterId: Long) : NavRequest
/** An `.ics` another app handed over. */
data class Import(val uri: Uri) : NavRequest
}
@@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -48,6 +49,8 @@ import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material.icons.rounded.Flag
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
@@ -57,14 +60,19 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material.icons.automirrored.rounded.Sort
import androidx.compose.material.icons.rounded.Repeat
import androidx.compose.material.icons.rounded.Sell
import androidx.compose.material.icons.rounded.Tune
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.ui.res.pluralStringResource
import de.jeanlucmakiola.agendula.domain.TaskSortOrder
import de.jeanlucmakiola.floret.components.revealOnFocus
import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.SelectedCheck
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -132,6 +140,7 @@ fun TaskListScreen(
onNewTask: () -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier,
onEditFilter: (Long) -> Unit = {},
viewModel: TaskListViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
@@ -150,6 +159,9 @@ fun TaskListScreen(
val listDeleted by viewModel.listDeleted.collectAsStateWithLifecycle()
// The list this screen is about is gone; there is nothing left to show.
LaunchedEffect(listDeleted) { if (listDeleted) onBack() }
val savedFilter = content?.savedFilter
val filterGone = filter is TaskFilter.Saved && content?.filter == filter && savedFilter == null
LaunchedEffect(filterGone) { if (filterGone) onBack() }
LaunchedEffect(listWriteFailure) {
if (listWriteFailure != null) {
delay(4_000)
@@ -178,7 +190,7 @@ fun TaskListScreen(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
MediumTopAppBar(
title = { Text(titleFor(filter, listName)) },
title = { Text(titleFor(filter, listName ?: savedFilter?.name)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
@@ -189,7 +201,14 @@ fun TaskListScreen(
},
actions = {
IconButton(onClick = { showSort = true }, enabled = content != null) {
Icon(Icons.AutoMirrored.Rounded.Sort, contentDescription = stringResource(R.string.sort_title))
BadgedBox(badge = { if (content?.selectedTags?.isNotEmpty() == true) Badge() }) {
Icon(Icons.Rounded.Tune, contentDescription = stringResource(R.string.view_options_title))
}
}
if (savedFilter != null) {
IconButton(onClick = { onEditFilter(savedFilter.id) }) {
Icon(Icons.Rounded.Edit, contentDescription = stringResource(R.string.filter_edit_title))
}
}
if (list != null) {
IconButton(
@@ -277,12 +296,13 @@ fun TaskListScreen(
}
if (showSort && content != null) {
OptionPicker(
title = stringResource(R.string.sort_title),
options = TaskSortOrder.entries,
selected = content.sortOrder,
label = { stringResource(sortLabel(it)) },
onSelect = { viewModel.setSortOrder(it); showSort = false },
ViewOptionsPicker(
sortOrder = content.sortOrder,
tags = content.availableTags,
selectedTags = content.selectedTags,
onSortOrder = viewModel::setSortOrder,
onToggleTag = viewModel::toggleTagFilter,
onClearTags = viewModel::clearTagFilter,
onDismiss = { showSort = false },
)
}
@@ -324,7 +344,7 @@ fun TaskListScreen(
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
@Composable
private fun TaskListBody(
state: TaskListUiState.Content,
@@ -349,7 +369,8 @@ private fun TaskListBody(
val listId = (filter as? TaskFilter.OfList)?.listId
// In a mixed view (a smart list) the list colour bar + name disambiguate
// rows; inside a single real list both are redundant.
val showListName = filter is TaskFilter.Smart
val showListName = filter is TaskFilter.Smart || filter is TaskFilter.Tagged ||
(filter is TaskFilter.Saved && state.savedFilter?.listIds?.size != 1)
// Completed tasks collapse away by default to keep the active list in focus.
var completedExpanded by rememberSaveable { mutableStateOf(false) }
// Parents the user expanded to reveal their subtasks inline. Children are
@@ -379,8 +400,16 @@ private fun TaskListBody(
// When the bottom quick-add bar is shown it already lives in the scaffold's
// bottomBar (so `inner` covers it); otherwise reserve room to clear the FAB.
val bottomBarShown = state.bottomAddBar && listId != null
// While typing, the viewport ends at the keyboard (or at the quick-add bar
// riding on it), so a focused add-subtask row can scroll fully above it.
val imeVisible = WindowInsets.isImeVisible
val viewport = when {
!imeVisible -> Modifier
bottomBarShown -> Modifier.padding(bottom = inner.calculateBottomPadding())
else -> Modifier.imePadding()
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
modifier = Modifier.fillMaxSize().then(viewport),
contentPadding = PaddingValues(
top = inner.calculateTopPadding() + 8.dp,
bottom = inner.calculateBottomPadding() + if (bottomBarShown) 16.dp else 96.dp,
@@ -659,7 +688,7 @@ internal fun TaskRowContent(
val listName = task.listName?.takeIf { showListName && it.isNotBlank() }
// Everything secondary collapses into one quiet supporting line below the title.
val hasMeta = task.priority != Priority.NONE || due != null || subtaskTotal > 0 ||
listName != null || task.isOccurrence
listName != null || task.isOccurrence || task.tags.isNotEmpty()
Surface(
onClick = onClick,
@@ -714,6 +743,7 @@ internal fun TaskRowContent(
subtaskTotal = subtaskTotal,
listName = listName,
listColor = task.effectiveColor,
tags = task.tags,
dark = dark,
)
}
@@ -745,6 +775,7 @@ private fun TaskMetaLine(
subtaskTotal: Int,
listName: String?,
listColor: Int,
tags: List<String>,
dark: Boolean,
) {
val muted = MaterialTheme.colorScheme.onSurfaceVariant
@@ -800,6 +831,28 @@ private fun TaskMetaLine(
}
}
}
if (tags.isNotEmpty()) {
add {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(3.dp),
) {
Icon(
Icons.Rounded.Sell,
contentDescription = stringResource(R.string.edit_tags_label),
tint = muted,
modifier = Modifier.size(13.dp),
)
Text(
tags.joinToString(", "),
style = style,
color = muted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
if (listName != null) {
add {
Text(
@@ -852,7 +905,7 @@ private fun AddSubtaskRow(
shape = cardShape(position, 22.dp, 6.dp),
// Same tone as the subtask rows it closes, so the group reads as one unit.
color = MaterialTheme.colorScheme.surfaceContainer,
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap),
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap).revealOnFocus(),
) {
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(start = 12.dp, end = 8.dp),
@@ -1169,11 +1222,65 @@ private fun CenteredMessage(text: String, inner: PaddingValues) {
}
}
/**
* Sort and tag filter in one place. Stays open while choosing, since several
* tags can be ticked; back leaves. A task must carry every ticked tag.
*/
@Composable
private fun titleFor(filter: TaskFilter, listName: String?): String = when (filter) {
is TaskFilter.OfList -> listName ?: stringResource(R.string.tasks_title)
private fun ViewOptionsPicker(
sortOrder: TaskSortOrder,
tags: List<String>,
selectedTags: Set<String>,
onSortOrder: (TaskSortOrder) -> Unit,
onToggleTag: (String) -> Unit,
onClearTags: () -> Unit,
onDismiss: () -> Unit,
) {
FullScreenPicker(
title = stringResource(R.string.view_options_title),
onDismiss = onDismiss,
actions = {
if (selectedTags.isNotEmpty()) {
TextButton(onClick = onClearTags) { Text(stringResource(R.string.view_filter_clear)) }
}
},
) {
GroupedSectionHeader(stringResource(R.string.sort_title))
TaskSortOrder.entries.forEachIndexed { index, order ->
val isSelected = order == sortOrder
GroupedRow(
title = stringResource(sortLabel(order)),
position = positionOf(index, TaskSortOrder.entries.size),
selected = isSelected,
trailing = if (isSelected) ({ SelectedCheck() }) else null,
onClick = { onSortOrder(order) },
)
}
if (tags.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
GroupedSectionHeader(stringResource(R.string.view_filter_tags))
tags.forEachIndexed { index, tag ->
val isSelected = tag in selectedTags
GroupedRow(
title = tag,
position = positionOf(index, tags.size),
selected = isSelected,
leading = { Icon(Icons.Rounded.Sell, contentDescription = null) },
trailing = if (isSelected) ({ SelectedCheck() }) else null,
onClick = { onToggleTag(tag) },
)
}
}
}
}
@Composable
private fun titleFor(filter: TaskFilter, name: String?): String = when (filter) {
is TaskFilter.OfList -> name ?: stringResource(R.string.tasks_title)
is TaskFilter.Saved -> name.orEmpty()
// Several lists at once only exists for the widget, which never routes here.
is TaskFilter.OfLists -> error("OfLists has no task-list screen")
is TaskFilter.Tagged -> filter.tag
is TaskFilter.Smart -> stringResource(
when (filter.list) {
SmartList.TODAY -> R.string.smart_today
@@ -7,8 +7,11 @@ import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.sync.RemoteLists
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
import de.jeanlucmakiola.agendula.domain.SavedFilter
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskFiltering
import de.jeanlucmakiola.agendula.domain.tagsInUse
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.TaskSortOrder
@@ -55,6 +58,14 @@ sealed interface TaskListUiState {
val sortOrder: TaskSortOrder = TaskSortOrder.DUE,
/** This real list is kept out of the smart lists. */
val hiddenFromSmartLists: Boolean = false,
/** Tags the unnarrowed view carries, offered as filter chips. */
val availableTags: List<String> = emptyList(),
/** The tags [tasks] is narrowed to; a task must carry all of them. */
val selectedTags: Set<String> = emptySet(),
/** The definition behind a [TaskFilter.Saved]; `null` once it's deleted. */
val savedFilter: SavedFilter? = null,
/** The filter this content was built for; a reused screen briefly holds the previous one's. */
val filter: TaskFilter? = null,
) : TaskListUiState
}
@@ -83,6 +94,8 @@ class TaskListViewModel @Inject constructor(
private val filter = MutableStateFlow<TaskFilter?>(null)
private val tagFilter = MutableStateFlow<Set<String>>(emptySet())
/** Occurrences swiped to delete but not yet committed — hidden until the undo lapses. */
private val pendingDeletes = MutableStateFlow<Map<String, Task>>(emptyMap())
@@ -101,20 +114,33 @@ class TaskListViewModel @Inject constructor(
combine(tasks, repository.taskLists()) { rows, lists ->
TaskListUiState.Content(rows, lists.firstOrNull { it.id == f.listId })
}
is TaskFilter.Smart ->
is TaskFilter.Smart, is TaskFilter.Tagged ->
tasks.map { TaskListUiState.Content(it) }
is TaskFilter.Saved ->
repository.filteredTasks(f).map {
TaskListUiState.Content(it.tasks, savedFilter = it.savedFilter)
}
// Several lists at once only exists for the widget, which never routes here.
is TaskFilter.OfLists -> error("OfLists has no task-list screen")
}
// Drop rows pending an undoable delete so the row vanishes on swipe
// while the actual provider delete waits for the snackbar to commit,
// and fold in the live UI settings (add-subtask row, bottom add bar).
combine(content, pendingDeletes, settingsPrefs.settings) { st, pending, settings ->
combine(content, pendingDeletes, settingsPrefs.settings, tagFilter) { st, pending, settings, tags ->
if (st is TaskListUiState.Content) {
val visible = settings.visibleIn(f, st.tasks).filter { it.occurrenceKey !in pending }
val available = tagsInUse(visible) - setOfNotNull((f as? TaskFilter.Tagged)?.tag)
// A tag that vanished can no longer be unticked, so it is dropped
// for good rather than narrowing again when it comes back.
val selected = tags.filterTo(LinkedHashSet()) { it in available }
if (selected.size != tags.size) tagFilter.value = selected
st.copy(
tasks = settings.visibleIn(f, st.tasks)
.filter { it.occurrenceKey !in pending }
filter = f,
tasks = visible
.filter { TaskFiltering.hasAllTags(it, selected) }
.sortedWith(settings.sortOrder.comparator()),
availableTags = available,
selectedTags = selected,
showAddSubtaskRow = settings.showAddSubtaskRow,
bottomAddBar = settings.bottomAddBar,
sortOrder = settings.sortOrder,
@@ -163,6 +189,12 @@ class TaskListViewModel @Inject constructor(
fun bind(taskFilter: TaskFilter) { filter.value = taskFilter }
fun toggleTagFilter(tag: String) {
tagFilter.value = if (tag in tagFilter.value) tagFilter.value - tag else tagFilter.value + tag
}
fun clearTagFilter() { tagFilter.value = emptySet() }
fun toggleComplete(task: Task) {
if (task.isCompleted) {
viewModelScope.launch {
@@ -169,6 +169,12 @@ private fun TaskWidgetBody(
val ready = data as? TaskWidgetData.Ready
val title = when (filter) {
is TaskFilter.Smart -> context.getString(smartListLabel(filter.list))
is TaskFilter.Tagged -> filter.tag
is TaskFilter.Saved -> if (ready == null) {
context.getString(R.string.widget_tasks_label)
} else {
ready.filterName ?: context.getString(R.string.filter_missing)
}
is TaskFilter.OfList, is TaskFilter.OfLists -> ready?.singleListName
?: context.getString(R.string.widget_tasks_label)
}
@@ -279,6 +285,8 @@ private fun TaskWidgetHeader(title: String, filter: TaskFilter, singleListId: Lo
// still opens straight to it, otherwise this just raises the app.
is TaskFilter.OfLists -> filter.listIds.singleOrNull()?.let { MainActivity.listIntent(context, it) }
?: Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
is TaskFilter.Tagged -> Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
is TaskFilter.Saved -> MainActivity.filterIntent(context, filter.id)
}
WidgetHeader(
title = title,
@@ -41,6 +41,7 @@ import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.domain.SavedFilter
import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskList
@@ -118,6 +119,7 @@ class TaskWidgetConfigActivity : ComponentActivity() {
setContent {
val settings by settingsPrefs.settings.collectAsState(initial = Settings())
val lists by remember { listsFlow }.collectAsState(initial = emptyList())
val savedFilters by settingsPrefs.savedFilters.collectAsState(initial = emptyList())
var current by remember { mutableStateOf<TaskFilter?>(null) }
var groupByList by remember { mutableStateOf(false) }
var background by remember { mutableStateOf(WidgetBackground.SOLID) }
@@ -143,6 +145,7 @@ class TaskWidgetConfigActivity : ComponentActivity() {
WidgetFilterPicker(
kind = kind,
lists = lists.filter { it.isVisible },
savedFilters = savedFilters,
dark = dark,
selected = current ?: DEFAULT_WIDGET_FILTER,
groupByList = groupByList,
@@ -193,6 +196,7 @@ class TaskWidgetConfigActivity : ComponentActivity() {
private fun WidgetFilterPicker(
kind: WidgetKind,
lists: List<TaskList>,
savedFilters: List<SavedFilter>,
selected: TaskFilter,
dark: Boolean,
groupByList: Boolean,
@@ -211,21 +215,25 @@ private fun WidgetFilterPicker(
var showBackgroundPicker by remember { mutableStateOf(false) }
var showThemePicker by remember { mutableStateOf(false) }
var showRowColorPicker by remember { mutableStateOf(false) }
// Smart lists and real lists are mutually exclusive; only real lists support
// picking more than one. `selectedLists` empty + `selectedSmart` non-null is
// a smart-list pick; the reverse (or both empty, before the stored filter
// loads) is a list pick.
// Smart lists, saved filters and real lists are mutually exclusive; only real
// lists support picking more than one. `selectedSmart` or `selectedFilter`
// non-null is that pick; otherwise (or before the stored filter loads) it's
// a list pick.
var selectedSmart by remember(selected) { mutableStateOf((selected as? TaskFilter.Smart)?.list) }
var selectedFilter by remember(selected) { mutableStateOf((selected as? TaskFilter.Saved)?.id) }
var selectedLists by remember(selected) {
mutableStateOf(
when (selected) {
is TaskFilter.OfList -> setOf(selected.listId)
is TaskFilter.OfLists -> selected.listIds
is TaskFilter.Smart -> emptySet()
is TaskFilter.Smart, is TaskFilter.Tagged, is TaskFilter.Saved -> emptySet()
},
)
}
val canSave = kind != WidgetKind.TASKS || selectedSmart != null || selectedLists.isNotEmpty()
// A widget whose filter was deleted keeps the dead id until something else is picked.
val liveFilter = selectedFilter?.takeIf { id -> savedFilters.any { it.id == id } }
val canSave = kind != WidgetKind.TASKS || selectedSmart != null || liveFilter != null ||
selectedLists.isNotEmpty()
FullScreenPicker(
title = stringResource(if (kind == WidgetKind.LISTS) R.string.widget_lists_config_title else R.string.widget_config_title),
@@ -239,8 +247,11 @@ private fun WidgetFilterPicker(
// a smart list, has to clear it here instead; otherwise it's
// stuck on with no way left to see or undo it.
if (selectedLists.size <= 1 && groupByList) onGroupByListChange(false)
selectedSmart?.let { onSave(TaskFilter.Smart(it)) }
?: onSave(TaskFilter.OfLists(selectedLists))
when {
selectedSmart != null -> onSave(TaskFilter.Smart(selectedSmart!!))
liveFilter != null -> onSave(TaskFilter.Saved(liveFilter))
else -> onSave(TaskFilter.OfLists(selectedLists))
}
},
) { Text(stringResource(android.R.string.ok)) }
},
@@ -254,9 +265,22 @@ private fun WidgetFilterPicker(
position = positionOf(index, WIDGET_SMART_LISTS.size),
selected = isSelected,
trailing = if (isSelected) ({ SelectedCheck() }) else null,
onClick = { selectedSmart = smart; selectedLists = emptySet() },
onClick = { selectedSmart = smart; selectedFilter = null; selectedLists = emptySet() },
)
}
if (savedFilters.isNotEmpty()) {
GroupedSectionHeader(stringResource(R.string.filters_header))
savedFilters.forEachIndexed { index, filter ->
val isSelected = filter.id == selectedFilter
GroupedRow(
title = filter.name,
position = positionOf(index, savedFilters.size),
selected = isSelected,
trailing = if (isSelected) ({ SelectedCheck() }) else null,
onClick = { selectedFilter = filter.id; selectedSmart = null; selectedLists = emptySet() },
)
}
}
if (lists.isNotEmpty()) {
GroupedSectionHeader(stringResource(R.string.lists_header))
lists.forEachIndexed { index, list ->
@@ -269,6 +293,7 @@ private fun WidgetFilterPicker(
trailing = if (isSelected) ({ SelectedCheck() }) else null,
onClick = {
selectedSmart = null
selectedFilter = null
selectedLists = if (isSelected) selectedLists - list.id else selectedLists + list.id
},
)
@@ -100,6 +100,8 @@ internal fun TaskFilter.widgetStorageValue(): String = when (this) {
is TaskFilter.OfList -> "list:$listId"
is TaskFilter.OfLists -> "lists:${listIds.sorted().joinToString(",")}"
is TaskFilter.Smart -> "smart:${list.name}"
is TaskFilter.Tagged -> error("A tag has no widget")
is TaskFilter.Saved -> "filter:$id"
}
/** Anything unreadable, or a smart list the widget doesn't offer, falls back to Today. */
@@ -111,6 +113,8 @@ internal fun parseWidgetFilter(raw: String?): TaskFilter {
value.startsWith("lists:") ->
value.removePrefix("lists:").split(",").mapNotNull { it.toLongOrNull()?.takeIf { id -> id > 0 } }
.toSet().takeIf { it.isNotEmpty() }?.let { TaskFilter.OfLists(it) }
value.startsWith("filter:") ->
value.removePrefix("filter:").toLongOrNull()?.takeIf { it > 0 }?.let { TaskFilter.Saved(it) }
value.startsWith("smart:") ->
WIDGET_SMART_LISTS.firstOrNull { it.name == value.removePrefix("smart:") }?.let { TaskFilter.Smart(it) }
else -> null
@@ -161,12 +165,14 @@ sealed interface TaskWidgetData {
val dynamicColor: Boolean,
val themeMode: ThemeMode,
val use24Hour: Boolean,
/** The saved filter's name when [filter] is one; `null` once it's deleted. */
val filterName: String? = null,
) : TaskWidgetData {
/** The one list's id, when [filter] names exactly one real list — seeds a widget's add button. */
val singleListId: Long? = when (filter) {
is TaskFilter.OfList -> filter.listId
is TaskFilter.OfLists -> filter.listIds.singleOrNull()
is TaskFilter.Smart -> null
is TaskFilter.Smart, is TaskFilter.Tagged, is TaskFilter.Saved -> null
}
/** [singleListId]'s name. */
@@ -271,10 +277,11 @@ internal fun Context.taskWidgetData(filter: TaskFilter, groupByList: Boolean): F
val ep = widgetEntryPoint()
val repository = ep.tasksRepository()
return combine(
repository.tasks(filter),
repository.filteredTasks(filter),
repository.taskLists(),
ep.settingsPrefs().settings,
) { tasks, lists, settings ->
) { filtered, lists, settings ->
val tasks = filtered.tasks
val listNames = lists.associate { it.id to it.name }
val data: TaskWidgetData = TaskWidgetData.Ready(
filter = filter,
@@ -291,6 +298,7 @@ internal fun Context.taskWidgetData(filter: TaskFilter, groupByList: Boolean): F
dynamicColor = settings.dynamicColor,
themeMode = settings.themeMode,
use24Hour = settings.timeFormat.is24Hour(DateFormat.is24HourFormat(this)),
filterName = filtered.savedFilter?.name,
)
data
}.catch { emit(TaskWidgetData.Unavailable) }
+73
View File
@@ -33,4 +33,77 @@
<string name="priority_medium">متوسط</string>
<string name="priority_high">مرتفع</string>
<string name="detail_list">القائمة</string>
<string name="add_account_step_of">الخطوة %1$d من %2$d</string>
<string name="onboarding_welcome_title">كل شئ كنت تقصد لفعله</string>
<string name="onboarding_welcome_body">مكان هادئ لمهامك، على هاتف يظل ملكًا لك. لنقم بإعداده — سيستغرق الأمر دقيقة.</string>
<string name="onboarding_welcome_lists_title">القوائم والمهام الفرعية</string>
<string name="onboarding_welcome_lists_body">ابقي الفصل بين العمل والمنزل والمشروع، وقسّم المهام الكبيرة.</string>
<string name="onboarding_welcome_reminders_title">التذكيرات التي تصل</string>
<string name="onboarding_welcome_reminders_body">إشعار عندما يكون شيء ما مستحق، وفقًا لجدولك الزمني.</string>
<string name="onboarding_welcome_sync_title">خادمك، أو لا شيء على الإطلاق</string>
<string name="add_account_browser_confirm_body">توجد صفحة تسجيل الدخول على خادمك، ليس في Agendula — لذا من القيّم إلقاء نظرة عليها قبل كتابة كلمة مرور حسابك هناك.</string>
<string name="onboarding_welcome_button">ابدأ</string>
<string name="reminder_onboarding_title">ابقى على اطلاع بمهامك</string>
<string name="shortcut_today">اليوم</string>
<string name="qs_tile_new_task">مهمة جديدة</string>
<string name="lists_reorder">إعادة ترتيب القوائم</string>
<string name="lists_reorder_done">انتهت إعادة الترتيب</string>
<string name="settings_title">الإعدادات</string>
<string name="settings_section_appearance">المظهر</string>
<string name="settings_about_source">المصدر</string>
<string name="settings_license">الترخيص</string>
<string name="settings_theme">السمة</string>
<string name="settings_theme_light">فاتح</string>
<string name="settings_theme_dark">داكن</string>
<string name="settings_section_reminders">التذكيرات</string>
<string name="settings_list_notifications">الإشعارات</string>
<string name="settings_group_data">البيانات</string>
<string name="settings_group_app">التطبيق</string>
<string name="settings_group_about">حول</string>
<string name="settings_time_format_auto">تلقائي</string>
<string name="settings_week_start_auto">تلقائي</string>
<string name="settings_section_storage">التخزين</string>
<string name="settings_section_accounts">الحسابات</string>
<string name="settings_list_reminder_inherits">الافتراضي (%1$s)</string>
<string name="settings_section_task_form">نموذج مهمة</string>
<string name="settings_about_support">دَعم التطوير</string>
<string name="settings_about_version">الإصدار %1$s</string>
<string name="settings_language">لغة التطبيق</string>
<string name="settings_language_auto">افتراضي النظام</string>
<string name="settings_translate">المساعدة في الترجمة</string>
<string name="settings_theme_system">يتبع النظام</string>
<string name="settings_dynamic_color">اللون الديناميكي</string>
<string name="settings_reminders">التذكيرات المستحقة</string>
<string name="settings_exact_alarms">التوقيت الدقيق</string>
<string name="settings_default_list">القائمة الافتراضية</string>
<string name="settings_time_format">تنسيق الوقت</string>
<string name="settings_time_format_24h">٢٤-ساعة (١٤:٠٠)</string>
<string name="settings_week_start_auto_summary">الحالي %1$s</string>
<string name="settings_privacy">سياسة الخصوصية</string>
<string name="settings_default_reminder_allday">مهام يوم كامل</string>
<string name="settings_reliable_delivery">توصيل موثوق</string>
<string name="settings_snooze_duration">مدة التأجيل</string>
<string name="settings_task_store">مخزن المهام</string>
<string name="settings_store_permission_denied">رُفِض الإذن</string>
<string name="settings_copy_running">جارٍ نسخ المهام…</string>
<string name="settings_export">تصدير المهام</string>
<string name="settings_about_author">بواسطة Jean-Luc Makiola</string>
<string name="settings_about_logo_desc">رمز تطبيق Agendula</string>
<string name="settings_report_problem">الإبلاغ عن مشكلة</string>
<string name="settings_default_reminder">متى لتذكير</string>
<string name="settings_group_look">المظهر و السلوك</string>
<string name="settings_bottom_add_bar">شريط الإضافة السريع السفلي</string>
<string name="settings_time_format_12h">١٢-ساعة (٢:٠٠ م)</string>
<string name="settings_week_start">الأسبوع يبدأ في</string>
<string name="settings_licences">تراخيص مفتوحة المصدر</string>
<string name="settings_notifications_blocked">الإشعارات متوقفة</string>
<string name="settings_store_own">على هذا الجهاز</string>
<string name="settings_allday_reminder_fires_at">يتم الإشعار في %1$s</string>
<string name="settings_store_external">تطبيق مهام آخر</string>
<string name="settings_store_switch_title">تبديل مخزن المهام؟</string>
<string name="settings_copy_confirm_title">نسخ المهام؟</string>
<string name="settings_legacy_import_retrying">جارٍ نقلهم الآن…</string>
<string name="settings_report_problem_hint">افتح متعقب المشكلات</string>
<string name="settings_copy_from_external">نسخ المهام من %1$s</string>
<string name="task_completed">مكتمل</string>
</resources>
+24 -1
View File
@@ -107,7 +107,7 @@
<string name="reminder_onboarding_body">Agendula benachrichtigt dich, wenn etwas fällig wird, also muss nichts in deinem Kopf leben.</string>
<string name="reminder_onboarding_enable_button">Erinnerungen aktivieren</string>
<string name="reminder_onboarding_skip_button">Nicht jetzt</string>
<string name="reminder_benefit_delivery_title">Agendula erinnert dich</string>
<string name="reminder_benefit_delivery_title">Ein kleiner Schubs, wenn es darauf ankommt</string>
<string name="reminder_benefit_delivery_body">Jede Aufgaben mit Fälligkeitsdatum bekommt seine eigene Benachrichtigung.</string>
<string name="reminder_benefit_timing_title">Auf deinem Zeitplan</string>
<string name="reminder_benefit_timing_body">Wähle, wie weit im Voraus du erinnert werden möchtest — für alles oder pro Liste.</string>
@@ -409,4 +409,27 @@
<string name="sync_notice_body">%1$s</string>
<string name="sync_notices_title">Änderungen durch letzte Synchronisierung</string>
<string name="sync_notices_dismiss">Verstanden</string>
<string name="recurrence_title">Wiederholung</string>
<plurals name="recurrence_with_count">
<item quantity="one">%1$s, %2$d Mal</item>
<item quantity="other">%1$s, %2$d Mal</item>
</plurals>
<string name="onboarding_exact_alarms_title">Genau rechtzeitig</string>
<string name="onboarding_exact_alarms_body">Android frägt dich, bevor dich eine App minutengenau benachrichtigen darf. Erlaube es und die Erinnerungen kommen, wenn sie fällig sind — ohne die Erlaubnis, könnten sich die Erinnerungen ein paar Minuten verspäten.</string>
<string name="onboarding_exact_alarms_allow">Erlaube genaues Timing</string>
<string name="settings_time_format_12h">12-Stunden (2:00 PM)</string>
<string name="settings_time_format_24h">24-Stunden (14:00)</string>
<string name="settings_time_format_auto_summary">Folgend dem System: %1$s</string>
<string name="settings_notifications_blocked_hint">Erinnerungen können dich nicht erreichen. Drücke, um Benachrichtigungen von Agendula zu erlauben.</string>
<string name="list_delete_confirm_message_synced">\"%1$s\" und alle Aufgaben davon werden von dem Server und jedem anderen Gerät, das damit verbunden ist, gelöscht. Das kann nicht rückgängig gemacht werden.</string>
<string name="onboarding_sync_devices_title">Jedes Gerät auf einmal</string>
<string name="onboarding_quick_add_bar">Schnell-Hinzufügen-Leiste</string>
<string name="onboarding_quick_add_button_hint">Ein schwebender Button, der das ganze Formular öffnet.</string>
<string name="settings_default_reminder_allday">Ganztägige Aufgaben</string>
<string name="onboarding_use_own_store">Stattdessen lokalen Speicher dieses Geräts nutzen</string>
<string name="settings_section_storage">Speicher</string>
<string name="settings_store_own">Auf diesem Gerät</string>
<string name="settings_store_own_hint">Agendulas eigener Speicher. Nichts weiteres zu installieren nötig.</string>
<string name="settings_store_external">Eine andere Aufgaben-App</string>
<string name="settings_store_permission_denied">Erlaubnis verweigert</string>
</resources>
+37
View File
@@ -39,6 +39,9 @@
<string name="lists_reorder_done">Done reordering</string>
<string name="lists_reorder_handle">Drag to reorder</string>
<string name="sort_title">Sort by</string>
<string name="view_options_title">Sort and filter</string>
<string name="view_filter_tags">Filter by tag</string>
<string name="view_filter_clear">Clear filter</string>
<string name="sort_due">Due date</string>
<string name="sort_priority">Priority</string>
<string name="sort_title_alpha">Title</string>
@@ -185,6 +188,9 @@
<string name="edit_location_label">Location</string>
<string name="edit_url_label">Link</string>
<string name="edit_url_hint">https://…</string>
<string name="edit_tags_label">Tags</string>
<string name="edit_tags_hint">Add a tag</string>
<string name="edit_tag_remove">Remove tag %1$s</string>
<string name="edit_discard_title">Discard changes?</string>
<string name="edit_discard_body">Your changes to this task haven’t been saved.</string>
<string name="edit_discard_confirm">Discard</string>
@@ -212,6 +218,31 @@
<!-- Lists overview -->
<string name="lists_header">Lists</string>
<string name="tags_header">Tags</string>
<string name="filters_header">Filters</string>
<string name="filters_empty_hint">Combine due dates, lists, tags and priority into a view of your own</string>
<string name="filter_new_title">New filter</string>
<string name="filter_edit_title">Edit filter</string>
<string name="filter_name_hint">Filter name</string>
<string name="filter_section_due">Due</string>
<string name="filter_section_priority">Priority</string>
<string name="filter_section_lists">Lists</string>
<string name="filter_section_tags">Tags</string>
<string name="filter_any_due">Any due date</string>
<string name="filter_any_list">Any list</string>
<string name="filter_any_tag">Any tag, or none</string>
<string name="filter_priority_any">Any priority</string>
<string name="filter_priority_low">Low and higher</string>
<string name="filter_priority_medium">Medium and higher</string>
<string name="filter_priority_high">High only</string>
<string name="filter_match_all_tags">Match all tags</string>
<string name="filter_match_all_on">A task needs every tag you picked</string>
<string name="filter_match_all_off">A task needs one of the tags you picked</string>
<string name="filter_delete">Delete filter</string>
<string name="filter_delete_title">Delete “%1$s”?</string>
<string name="filter_delete_body">Only the filter goes. Its tasks stay where they are.</string>
<string name="filter_delete_confirm">Delete</string>
<string name="filter_missing">Filter deleted</string>
<string name="new_task">New task</string>
<string name="lists_failure">Could not read your tasks.</string>
<string name="lists_empty">No task lists yet.</string>
@@ -823,4 +854,10 @@
<string name="widget_lists_config_title">Lists to show</string>
<string name="widget_week_label">Week</string>
<string name="widget_week_description">A seven-day strip of task counts</string>
<!-- Permission another app requests to read Agendula's tasks -->
<string name="permission_read_tasks_label">read Agendula tasks</string>
<string name="permission_read_tasks_description">Allows the app to read your task lists and tasks stored in Agendula, including their reminders and tags.</string>
<!-- Permission another app requests to add, change or delete Agendula's tasks -->
<string name="permission_write_tasks_label">add, change and delete Agendula tasks</string>
<string name="permission_write_tasks_description">Allows the app to add, change, complete and delete tasks stored in Agendula, including their reminders and tags. It cannot change or delete task lists.</string>
</resources>
@@ -0,0 +1,92 @@
package de.jeanlucmakiola.agendula.data.prefs
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.domain.DueWindow
import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.agendula.domain.SavedFilter
import de.jeanlucmakiola.agendula.domain.TagMatch
import org.junit.jupiter.api.Test
class SavedFilterCodecTest {
private val filter = SavedFilter(
id = 3,
name = "Work | today, 100%",
due = setOf(DueWindow.TODAY, DueWindow.OVERDUE),
listIds = setOf(4L, 9L),
tags = setOf("a,b", "größe", "x|y"),
tagMatch = TagMatch.ALL,
minPriority = Priority.MEDIUM,
)
@Test
fun `a filter round-trips, separators and all`() {
val raw = SavedFilterCodec.upsert(null, filter, "OWN")
assertThat(SavedFilterCodec.decode(raw, "OWN")).containsExactly(filter)
}
@Test
fun `upsert replaces by id and keeps the order`() {
val first = filter.copy(id = 1, name = "one")
var raw = SavedFilterCodec.upsert(null, first, "OWN")
raw = SavedFilterCodec.upsert(raw, filter, "OWN")
raw = SavedFilterCodec.upsert(raw, first.copy(name = "renamed"), "OWN")
assertThat(SavedFilterCodec.decode(raw, "OWN").map { it.name }).containsExactly("renamed", filter.name).inOrder()
}
@Test
fun `list ids belong to their store`() {
val raw = SavedFilterCodec.upsert(null, filter, "OWN")
val elsewhere = SavedFilterCodec.decode(raw, "EXTERNAL").single()
assertThat(elsewhere.listIds).isEmpty()
assertThat(elsewhere.listsElsewhere).isTrue()
assertThat(SavedFilterCodec.decode(raw, "OWN").single().listsElsewhere).isFalse()
}
@Test
fun `choosing any list in one store drops the other store's lists`() {
var raw = SavedFilterCodec.upsert(null, filter, "OWN")
raw = SavedFilterCodec.upsert(raw, filter.copy(listIds = emptySet(), listsElsewhere = false), "EXTERNAL")
assertThat(SavedFilterCodec.decode(raw, "OWN").single().anyList).isTrue()
}
@Test
fun `unticking the last list here keeps the other store's lists`() {
var raw = SavedFilterCodec.upsert(null, filter, "OWN")
raw = SavedFilterCodec.upsert(raw, filter.copy(listIds = setOf(1L)), "EXTERNAL")
val external = SavedFilterCodec.decode(raw, "EXTERNAL").single()
assertThat(external.listsElsewhere).isTrue()
raw = SavedFilterCodec.upsert(raw, external.copy(listIds = emptySet()), "EXTERNAL")
assertThat(SavedFilterCodec.decode(raw, "OWN").single().listIds).containsExactly(4L, 9L)
assertThat(SavedFilterCodec.decode(raw, "EXTERNAL").single().anyList).isFalse()
}
@Test
fun `saving untouched in the other store keeps the lists`() {
var raw = SavedFilterCodec.upsert(null, filter, "OWN")
val elsewhere = SavedFilterCodec.decode(raw, "EXTERNAL").single()
raw = SavedFilterCodec.upsert(raw, elsewhere.copy(name = "renamed"), "EXTERNAL")
assertThat(SavedFilterCodec.decode(raw, "OWN").single().listIds).containsExactly(4L, 9L)
}
@Test
fun `editing in one store keeps the other store's lists`() {
var raw = SavedFilterCodec.upsert(null, filter, "OWN")
raw = SavedFilterCodec.upsert(raw, filter.copy(listIds = setOf(1L)), "EXTERNAL")
assertThat(SavedFilterCodec.decode(raw, "OWN").single().listIds).containsExactly(4L, 9L)
assertThat(SavedFilterCodec.decode(raw, "EXTERNAL").single().listIds).containsExactly(1L)
}
@Test
fun `remove drops only that filter`() {
var raw = SavedFilterCodec.upsert(null, filter.copy(id = 1), "OWN")
raw = SavedFilterCodec.upsert(raw, filter, "OWN")
assertThat(SavedFilterCodec.decode(SavedFilterCodec.remove(raw, 1), "OWN").map { it.id }).containsExactly(3L)
}
@Test
fun `broken lines are skipped`() {
val raw = "nonsense\n" + SavedFilterCodec.upsert(null, filter, "OWN") + "\n7|short\n8|bad%zz||||ANY|NONE"
assertThat(SavedFilterCodec.decode(raw, "OWN")).containsExactly(filter)
}
}
@@ -148,6 +148,17 @@ class TaskWriteMapperTest {
assertThat(undone[Tasks.COMPLETED]).isNull()
}
@Test
fun `category row carries only the name, so the provider resolves the category itself`() {
val values = TaskWriteMapper.categoryValues(taskId = 12L, name = "Work")
assertThat(values).containsExactly(
TasksContract.Properties.TASK_ID, 12L,
TasksContract.Properties.MIMETYPE, "vnd.android.cursor.item/category",
TasksContract.Category.NAME, "Work",
)
}
@Test
fun `alarm carries every column the provider's validator demands`() {
val values = TaskWriteMapper.alarmValues(taskId = 12L, minutesBeforeDue = 30)
@@ -303,6 +303,65 @@ class VTodoMapperTest {
}
}
@Nested
inner class Categories {
@Test
fun `a single plain line is claimed into the column`() {
val mapped = VTodoMapper.read(vtodo("CATEGORIES:Work,Home\\, Garden"))
assertThat(mapped.entity.categories).containsExactly("Work", "Home, Garden").inOrder()
assertThat(mapped.entity.unknownProperties.orEmpty()).doesNotContain("CATEGORIES")
}
@Test
fun `several lines are read but round-trip from the residue`() {
val mapped = VTodoMapper.read(vtodo("CATEGORIES:Work", "CATEGORIES:Errands"))
assertThat(mapped.entity.categories).containsExactly("Work", "Errands").inOrder()
val written = VTodoMapper.write(mapped.entity).properties("CATEGORIES").map { it.value }
assertThat(written).containsExactly("Work", "Errands")
}
@Test
fun `a line the column would not reproduce exactly stays in the residue`() {
val mapped = VTodoMapper.read(vtodo("CATEGORIES;LANGUAGE=de:Arbeit", "SUMMARY:x"))
assertThat(mapped.entity.categories).containsExactly("Arbeit")
assertThat(VTodoMapper.read(vtodo("CATEGORIES:a, b")).entity.unknownProperties)
.contains("CATEGORIES:a, b")
val written = VTodoMapper.write(mapped.entity).properties("CATEGORIES")
assertThat(written.single().param("LANGUAGE")).isEqualTo("de")
}
@Test
fun `editing the tags evicts the residue copies and authors one line`() {
val mapped = VTodoMapper.read(vtodo("CATEGORIES:Work", "CATEGORIES:Errands"))
val edited = mapped.entity.copy(categories = listOf("Work", "Home"))
val written = VTodoMapper.write(edited).properties("CATEGORIES")
assertThat(written.single().value).isEqualTo("Work,Home")
assertThat(written.single().params).isEmpty()
}
@Test
fun `no tags writes no line`() {
assertThat(VTodoMapper.write(entity()).properties("CATEGORIES")).isEmpty()
}
@Test
fun `a duplicated or blank tag is dropped and the line stays in the residue`() {
val mapped = VTodoMapper.read(vtodo("CATEGORIES:Work, ,Work"))
assertThat(mapped.entity.categories).containsExactly("Work")
assertThat(VTodoMapper.write(mapped.entity).properties("CATEGORIES").single().value).isEqualTo("Work, ,Work")
}
@Test
fun `an unclean column is cleaned on the way out`() {
val written = VTodoMapper.write(entity().copy(categories = listOf(" a ", "", "a", "b")))
assertThat(written.properties("CATEGORIES").single().value).isEqualTo("a,b")
}
}
@Nested
inner class Validation {
@@ -39,6 +39,8 @@ class VTodoRoundTripTest {
"mixed-value-types",
"malformed-scalars",
"two-timezones",
"categories-shapes",
"categories-split",
)
@TestFactory
@@ -0,0 +1,96 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import kotlin.time.Instant
class ProviderIdsTest {
@Test
fun `an instance id gives back its series`() {
val id = ProviderIds.instance(1234, Instant.parse("2026-10-15T09:00:00Z"))
assertThat(ProviderIds.instanceSeries(id)).isEqualTo(1234L)
assertThat(ProviderIds.instanceSeconds(id)).isEqualTo(Instant.parse("2026-10-15T09:00:00Z").epochSeconds)
assertThat(id).isGreaterThan(0L)
}
@Test
fun `a plain task's instance has no time`() {
assertThat(ProviderIds.instanceSeconds(ProviderIds.instance(7, null))).isEqualTo(0L)
}
@Test
fun `occurrences of one series and of different series never share an id`() {
val at = Instant.parse("2026-10-15T09:00:00Z")
val ids = listOf(
ProviderIds.instance(1, at),
ProviderIds.instance(1, Instant.fromEpochSeconds(at.epochSeconds + 1)),
ProviderIds.instance(2, at),
ProviderIds.instance(1, null),
)
assertThat(ids).containsNoDuplicates()
}
@Test
fun `alarm ids are even and tag ids odd`() {
val alarm = ProviderIds.alarm(42)
val tags = ProviderIds.categories(42, listOf("a", "b"))
assertThat(ProviderIds.isAlarm(alarm)).isTrue()
assertThat(ProviderIds.alarmRow(alarm)).isEqualTo(42L)
tags.values.forEach {
assertThat(ProviderIds.isAlarm(it)).isFalse()
assertThat(ProviderIds.categoryTask(it)).isEqualTo(42L)
}
}
@Test
fun `a tag's id depends on its task and spelling only`() {
val alone = ProviderIds.categories(5, listOf("home"))
val among = ProviderIds.categories(5, listOf("work", "home", "errand"))
assertThat(among["home"]).isEqualTo(alone["home"])
assertThat(ProviderIds.categories(6, listOf("home"))["home"]).isNotEqualTo(alone["home"])
}
@Test
fun `two spellings with one hash still get two ids`() {
// "Aa" and "BB" share String.hashCode().
val ids = ProviderIds.categories(5, listOf("Aa", "BB"))
assertThat(ids.values).containsNoDuplicates()
assertThat(ids.values.map(ProviderIds::categoryTask)).containsExactly(5L, 5L)
}
@Test
fun `colliding tags keep their ids whatever their order`() {
val hash = { _: String -> 7L }
assertThat(ProviderIds.categories(5, listOf("b", "a"), hash))
.isEqualTo(ProviderIds.categories(5, listOf("a", "b"), hash))
}
@Test
fun `colliding tags stay inside their task's bits`() {
val ids = ProviderIds.categories(5, listOf("a", "b", "c"), hash = { 0x7FFFFFFFL })
assertThat(ids.values).containsNoDuplicates()
ids.values.forEach {
assertThat(ProviderIds.categoryTask(it)).isEqualTo(5L)
assertThat(ProviderIds.isAlarm(it)).isFalse()
}
}
@Test
fun `tags of neighbouring tasks never share an id`() {
val names = (1..200).map { "tag$it" }
val ids = (1L..50L).flatMap { ProviderIds.categories(it, names).values }
assertThat(ids).containsNoDuplicates()
}
}
@@ -0,0 +1,371 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.tasks.TasksContract
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Alarm
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Category
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListRow
import de.jeanlucmakiola.agendula.domain.TaskStatus
import org.junit.jupiter.api.Test
import java.time.ZoneId
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
import kotlin.time.Instant
class ProviderSnapshotTest {
private val now = Instant.parse("2026-10-10T08:00:00Z")
private val local = TaskListRow(TaskListEntity(id = 1, name = "Home", color = 0xFF00FF00.toInt()), null)
private val synced = TaskListRow(TaskListEntity(id = 2, name = "Work", color = 0, accountId = 5), "Nextcloud")
private fun build(tasks: List<TaskEntity>, alarms: List<TaskAlarmEntity> = emptyList()) =
ProviderSnapshot.build(
lists = listOf(local, synced),
tasks = tasks,
alarms = alarms,
accountType = "caldav-type",
now = now,
zone = ZoneId.of("Europe/Berlin"),
)
@Test
fun `lists report the local account for device-only lists and ours for synced ones`() {
val rows = build(emptyList()).rows.getValue(ProviderTable.LISTS)
assertThat(rows.map { it[Lists.ACCOUNT_TYPE] })
.containsExactly(TasksContract.LOCAL_ACCOUNT_TYPE, "caldav-type").inOrder()
assertThat(rows.map { it[Lists.ACCOUNT_NAME] })
.containsExactly(TasksContract.LOCAL_ACCOUNT_NAME, "Nextcloud").inOrder()
}
@Test
fun `a plain task is one task row and one instance`() {
val due = now + 1.days
val task = TaskEntity(id = 7, listId = 1, uid = "u7", title = "Milk", due = due, priority = 3)
val snapshot = build(listOf(task))
val row = snapshot.rows.getValue(ProviderTable.TASKS).single()
val instance = snapshot.rows.getValue(ProviderTable.INSTANCES).single()
assertThat(row[Tasks.ID]).isEqualTo(7L)
assertThat(row[Tasks.PRIORITY]).isEqualTo(3)
assertThat(row[Tasks.LIST_NAME]).isEqualTo("Home")
assertThat(row[Tasks.IS_CLOSED]).isEqualTo(0)
assertThat(row[Tasks.IS_NEW]).isEqualTo(1)
assertThat(instance[Instances.TASK_ID]).isEqualTo(7L)
assertThat(instance[Instances.INSTANCE_DUE]).isEqualTo(due.toEpochMilliseconds())
assertThat(instance[Instances.IS_RECURRING]).isEqualTo(0)
assertThat(instance[Instances.DISTANCE_FROM_CURRENT]).isEqualTo(0)
}
@Test
fun `a completed task is closed`() {
val task = TaskEntity(id = 1, listId = 1, uid = "u", status = TaskStatus.COMPLETED, completedAt = now)
val row = build(listOf(task)).rows.getValue(ProviderTable.TASKS).single()
assertThat(row[Tasks.IS_CLOSED]).isEqualTo(1)
assertThat(row[Tasks.STATUS]).isEqualTo(TasksContract.STATUS_COMPLETED)
assertThat(row[Tasks.COMPLETED]).isEqualTo(now.toEpochMilliseconds())
}
@Test
fun `an override replaces the occurrence it names`() {
val start = Instant.parse("2026-10-09T09:00:00Z")
val master = TaskEntity(
id = 10, listId = 1, uid = "s", title = "Run", dtstart = start, due = start + 1.hours,
rrule = "FREQ=DAILY;COUNT=3", timezone = "UTC",
)
val second = start + 1.days
val override = master.copy(
id = 11, rrule = null, recurrenceId = second, masterId = 10, title = "Run long",
dtstart = second + 2.hours, due = second + 4.hours,
)
val snapshot = build(listOf(master, override))
val instances = snapshot.rows.getValue(ProviderTable.INSTANCES)
assertThat(instances.map { it[Instances.TASK_ID] }).containsExactly(10L, 11L, 10L).inOrder()
assertThat(instances.map { it[Tasks.TITLE] }).containsExactly("Run", "Run long", "Run").inOrder()
assertThat(instances[1][Instances.INSTANCE_ORIGINAL_TIME]).isEqualTo(second.toEpochMilliseconds())
assertThat(instances[1][Instances.INSTANCE_DURATION]).isEqualTo(2.hours.inWholeMilliseconds)
assertThat(instances.map { it[Tasks.ID] }).containsNoDuplicates()
val overrideRow = snapshot.rows.getValue(ProviderTable.TASKS).single { it[Tasks.ID] == 11L }
assertThat(overrideRow[Tasks.ORIGINAL_INSTANCE_ID]).isEqualTo(10L)
assertThat(overrideRow[Tasks.ORIGINAL_INSTANCE_TIME]).isEqualTo(second.toEpochMilliseconds())
}
@Test
fun `an all-day date sorts at local midnight`() {
val day = Instant.parse("2026-10-12T00:00:00Z")
val task = TaskEntity(id = 1, listId = 1, uid = "u", due = day, isAllDay = true)
val instance = build(listOf(task)).rows.getValue(ProviderTable.INSTANCES).single()
assertThat(instance[Instances.INSTANCE_DUE]).isEqualTo(day.toEpochMilliseconds())
assertThat(instance[Instances.INSTANCE_DUE_SORTING]).isEqualTo((day - 2.hours).toEpochMilliseconds())
}
@Test
fun `alarms and tags become properties`() {
val task = TaskEntity(id = 3, listId = 2, uid = "u", categories = listOf("errand", "home"))
val alarm = TaskAlarmEntity(id = 1, taskId = 3, minutesBefore = 15)
val snapshot = build(listOf(task), listOf(alarm))
val properties = snapshot.rows.getValue(ProviderTable.PROPERTIES)
assertThat(snapshot.rows.getValue(ProviderTable.TASKS).single()[Tasks.HAS_ALARMS]).isEqualTo(1)
val alarmRow = properties.single { it[Properties.MIMETYPE] == Alarm.MIMETYPE }
assertThat(alarmRow[Alarm.MINUTES_BEFORE]).isEqualTo("15")
assertThat(alarmRow[Alarm.REFERENCE]).isEqualTo(Alarm.REFERENCE_DUE.toString())
assertThat(properties.filter { it[Properties.MIMETYPE] == Category.MIMETYPE }.map { it[Category.NAME] })
.containsExactly("errand", "home")
assertThat(properties.map { it[Properties.PROPERTY_ID] }).containsNoDuplicates()
}
@Test
fun `every row carries exactly its table's columns`() {
val task = TaskEntity(id = 3, listId = 1, uid = "u", categories = listOf("a"))
val snapshot = build(listOf(task), listOf(TaskAlarmEntity(id = 1, taskId = 3, minutesBefore = 0)))
ProviderTable.entries.forEach { table ->
val columns = table.columns.map { it.first }.toSet()
snapshot.rows.getValue(table).forEach { assertThat(columns).containsAtLeastElementsIn(it.keys) }
}
}
@Test
fun `the empty snapshot has every table and no rows`() {
assertThat(ProviderSnapshot.EMPTY.rows.keys).containsExactlyElementsIn(ProviderTable.entries)
assertThat(ProviderSnapshot.EMPTY.rows.values.flatten()).isEmpty()
}
@Test
fun `lists carry their name, colour and flags`() {
val hidden = TaskListRow(TaskListEntity(id = 3, name = "Old", color = 7, isVisible = false, isSynced = false), null)
val row = ProviderSnapshot.build(listOf(hidden), emptyList(), emptyList(), "t", now)
.rows.getValue(ProviderTable.LISTS).single()
assertThat(row[Lists.ID]).isEqualTo(3L)
assertThat(row[Lists.NAME]).isEqualTo("Old")
assertThat(row[Lists.COLOR]).isEqualTo(7)
assertThat(row[Lists.VISIBLE]).isEqualTo(0)
assertThat(row[Lists.SYNC_ENABLED]).isEqualTo(0)
}
@Test
fun `a task reports its list's account`() {
val task = TaskEntity(id = 1, listId = 2, uid = "u")
val row = build(listOf(task)).rows.getValue(ProviderTable.TASKS).single()
assertThat(row[Tasks.LIST_NAME]).isEqualTo("Work")
assertThat(row[Tasks.ACCOUNT_NAME]).isEqualTo("Nextcloud")
assertThat(row[Tasks.ACCOUNT_TYPE]).isEqualTo("caldav-type")
}
@Test
fun `a task whose list is gone is left out everywhere`() {
val orphan = TaskEntity(id = 4, listId = 99, uid = "u", categories = listOf("x"))
val alarm = TaskAlarmEntity(id = 1, taskId = 4, minutesBefore = 5)
val snapshot = build(listOf(orphan), listOf(alarm))
assertThat(snapshot.rows.getValue(ProviderTable.TASKS)).isEmpty()
assertThat(snapshot.rows.getValue(ProviderTable.INSTANCES)).isEmpty()
assertThat(snapshot.rows.getValue(ProviderTable.PROPERTIES)).isEmpty()
}
@Test
fun `task fields map to their OpenTasks columns`() {
val start = Instant.parse("2026-10-11T09:00:00Z")
val task = TaskEntity(
id = 5, listId = 1, uid = "uid-5", title = "T", description = "D", location = "L", url = "https://x",
color = 42, classification = 1, percentComplete = 50, dtstart = start, due = start + 1.hours,
timezone = "Europe/Berlin", parentId = 2, sortOrder = 3, createdAt = now, lastModified = now,
)
val row = build(listOf(task)).rows.getValue(ProviderTable.TASKS).single()
assertThat(row[Tasks.UID]).isEqualTo("uid-5")
assertThat(row[Tasks.TITLE]).isEqualTo("T")
assertThat(row[Tasks.DESCRIPTION]).isEqualTo("D")
assertThat(row[Tasks.LOCATION]).isEqualTo("L")
assertThat(row[Tasks.URL]).isEqualTo("https://x")
assertThat(row[Tasks.TASK_COLOR]).isEqualTo(42)
assertThat(row[Tasks.CLASSIFICATION]).isEqualTo(1)
assertThat(row[Tasks.PERCENT_COMPLETE]).isEqualTo(50)
assertThat(row[Tasks.DTSTART]).isEqualTo(start.toEpochMilliseconds())
assertThat(row[Tasks.DUE]).isEqualTo((start + 1.hours).toEpochMilliseconds())
assertThat(row[Tasks.TZ]).isEqualTo("Europe/Berlin")
assertThat(row[Tasks.IS_ALLDAY]).isEqualTo(0)
assertThat(row[Tasks.PARENT_ID]).isEqualTo(2L)
assertThat(row[Tasks.SORTING]).isEqualTo(3)
assertThat(row[Tasks.CREATED]).isEqualTo(now.toEpochMilliseconds())
assertThat(row[Tasks.LAST_MODIFIED]).isEqualTo(now.toEpochMilliseconds())
assertThat(row[Tasks.DELETED]).isEqualTo(0)
assertThat(row[Tasks.HAS_ALARMS]).isEqualTo(0)
assertThat(row[Tasks.ORIGINAL_INSTANCE_ID]).isNull()
assertThat(row[Tasks.ORIGINAL_INSTANCE_ALLDAY]).isNull()
}
@Test
fun `a task with progress is no longer new`() {
val task = TaskEntity(id = 1, listId = 1, uid = "u", percentComplete = 10)
assertThat(build(listOf(task)).rows.getValue(ProviderTable.TASKS).single()[Tasks.IS_NEW]).isEqualTo(0)
}
@Test
fun `a cancelled task is closed`() {
val task = TaskEntity(id = 1, listId = 1, uid = "u", status = TaskStatus.CANCELLED)
val row = build(listOf(task)).rows.getValue(ProviderTable.TASKS).single()
assertThat(row[Tasks.IS_CLOSED]).isEqualTo(1)
assertThat(row[Tasks.STATUS]).isEqualTo(TasksContract.STATUS_CANCELLED)
}
@Test
fun `a series expands into one instance per occurrence`() {
val start = Instant.parse("2026-10-09T09:00:00Z")
val master = TaskEntity(
id = 10, listId = 1, uid = "s", dtstart = start, due = start + 1.hours,
rrule = "FREQ=DAILY;COUNT=3", timezone = "UTC",
)
val snapshot = build(listOf(master))
val instances = snapshot.rows.getValue(ProviderTable.INSTANCES)
assertThat(snapshot.rows.getValue(ProviderTable.TASKS)).hasSize(1)
assertThat(instances.map { it[Instances.TASK_ID] }).containsExactly(10L, 10L, 10L)
assertThat(instances.map { it[Instances.IS_RECURRING] }).containsExactly(1, 1, 1)
assertThat(instances.map { it[Instances.INSTANCE_START] }).containsExactlyElementsIn(
(0..2).map { (start + it.days).toEpochMilliseconds() },
).inOrder()
assertThat(instances.map { it[Instances.INSTANCE_ORIGINAL_TIME] })
.isEqualTo(instances.map { it[Instances.INSTANCE_START] })
assertThat(instances.map { it[Instances.INSTANCE_DURATION] }).containsExactly(
1.hours.inWholeMilliseconds, 1.hours.inWholeMilliseconds, 1.hours.inWholeMilliseconds,
)
// 10-09 is past, 10-10 09:00 is the next one at or after 08:00.
assertThat(instances.map { it[Instances.DISTANCE_FROM_CURRENT] }).containsExactly(-1, 0, 1).inOrder()
}
@Test
fun `a due-anchored series has no start`() {
val due = Instant.parse("2026-10-12T17:00:00Z")
val master = TaskEntity(id = 10, listId = 1, uid = "s", due = due, rrule = "FREQ=WEEKLY;COUNT=2", timezone = "UTC")
val instances = build(listOf(master)).rows.getValue(ProviderTable.INSTANCES)
assertThat(instances.map { it[Instances.INSTANCE_START] }).containsExactly(null, null)
assertThat(instances.map { it[Instances.INSTANCE_DUE] }).containsExactly(
due.toEpochMilliseconds(), (due + 7.days).toEpochMilliseconds(),
).inOrder()
assertThat(instances.map { it[Instances.INSTANCE_DURATION] }).containsExactly(null, null)
}
@Test
fun `instance ids are unique across series and plain tasks`() {
val start = Instant.parse("2026-10-09T09:00:00Z")
val a = TaskEntity(id = 1, listId = 1, uid = "a", dtstart = start, rrule = "FREQ=DAILY;COUNT=4", timezone = "UTC")
val b = TaskEntity(id = 2, listId = 1, uid = "b", dtstart = start, rrule = "FREQ=DAILY;COUNT=4", timezone = "UTC")
val plain = TaskEntity(id = 3, listId = 2, uid = "c")
val ids = build(listOf(a, b, plain)).rows.getValue(ProviderTable.INSTANCES).map { it[Tasks.ID] }
assertThat(ids).hasSize(9)
assertThat(ids).containsNoDuplicates()
}
@Test
fun `an override is flagged all-day like the occurrence it replaces`() {
val day = Instant.parse("2026-10-09T00:00:00Z")
val master = TaskEntity(id = 1, listId = 1, uid = "s", due = day, isAllDay = true, rrule = "FREQ=DAILY;COUNT=2")
val override = master.copy(id = 2, rrule = null, masterId = 1, recurrenceId = day + 1.days)
val row = build(listOf(master, override)).rows.getValue(ProviderTable.TASKS).single { it[Tasks.ID] == 2L }
assertThat(row[Tasks.ORIGINAL_INSTANCE_ALLDAY]).isEqualTo(1)
}
@Test
fun `a timed date sorts as itself`() {
val due = Instant.parse("2026-10-12T15:30:00Z")
val task = TaskEntity(id = 1, listId = 1, uid = "u", due = due, dtstart = due - 1.hours)
val instance = build(listOf(task)).rows.getValue(ProviderTable.INSTANCES).single()
assertThat(instance[Instances.INSTANCE_DUE_SORTING]).isEqualTo(due.toEpochMilliseconds())
assertThat(instance[Instances.INSTANCE_START_SORTING]).isEqualTo((due - 1.hours).toEpochMilliseconds())
}
@Test
fun `an alarm before the start says so`() {
val task = TaskEntity(id = 3, listId = 1, uid = "u")
val alarm = TaskAlarmEntity(id = 1, taskId = 3, minutesBefore = 60, reference = AlarmReference.START, message = "Go")
val row = build(listOf(task), listOf(alarm)).rows.getValue(ProviderTable.PROPERTIES).single()
assertThat(row[Properties.TASK_ID]).isEqualTo(3L)
assertThat(row[Alarm.REFERENCE]).isEqualTo(Alarm.REFERENCE_START.toString())
assertThat(row[Alarm.MESSAGE]).isEqualTo("Go")
assertThat(row[Alarm.ALARM_TYPE]).isEqualTo(Alarm.TYPE_MESSAGE.toString())
}
@Test
fun `an instance keeps its id when other occurrences come and go`() {
val start = Instant.parse("2026-10-09T09:00:00Z")
val master = TaskEntity(id = 10, listId = 1, uid = "s", dtstart = start, rrule = "FREQ=DAILY;COUNT=4", timezone = "UTC")
val plain = TaskEntity(id = 3, listId = 1, uid = "p")
fun ids(vararg tasks: TaskEntity) = build(tasks.toList()).rows.getValue(ProviderTable.INSTANCES)
.associate { it[Instances.INSTANCE_ORIGINAL_TIME] to it[Tasks.ID] }
val before = ids(plain, master)
val after = ids(master.copy(exdate = "20261010T090000Z"))
val third = (start + 2.days).toEpochMilliseconds()
assertThat(after[third]).isEqualTo(before[third])
assertThat(after).hasSize(3)
}
@Test
fun `an overridden occurrence keeps the id it had before`() {
val start = Instant.parse("2026-10-09T09:00:00Z")
val master = TaskEntity(id = 10, listId = 1, uid = "s", dtstart = start, rrule = "FREQ=DAILY;COUNT=3", timezone = "UTC")
val override = master.copy(id = 11, rrule = null, masterId = 10, recurrenceId = start + 1.days, title = "x")
fun idOfSecond(vararg tasks: TaskEntity) = build(tasks.toList()).rows.getValue(ProviderTable.INSTANCES)
.single { it[Instances.INSTANCE_ORIGINAL_TIME] == (start + 1.days).toEpochMilliseconds() }[Tasks.ID]
assertThat(idOfSecond(master, override)).isEqualTo(idOfSecond(master))
}
@Test
fun `a property keeps its id when other properties come and go`() {
val a = TaskEntity(id = 3, listId = 1, uid = "a", categories = listOf("one", "two"))
val b = TaskEntity(id = 4, listId = 1, uid = "b", categories = listOf("three"))
fun ids(tasks: List<TaskEntity>, alarms: List<TaskAlarmEntity>) = build(tasks, alarms)
.rows.getValue(ProviderTable.PROPERTIES)
.associate { (it[Category.NAME] ?: it[Alarm.MINUTES_BEFORE]) to it[Properties.PROPERTY_ID] }
val first = TaskAlarmEntity(id = 7, taskId = 3, minutesBefore = 5)
val second = TaskAlarmEntity(id = 8, taskId = 4, minutesBefore = 10)
val before = ids(listOf(a, b), listOf(first, second))
val after = ids(listOf(a.copy(categories = listOf("two")), b), listOf(second))
assertThat(after["two"]).isEqualTo(before["two"])
assertThat(after["three"]).isEqualTo(before["three"])
assertThat(after["10"]).isEqualTo(before["10"])
}
}
@@ -0,0 +1,70 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import org.junit.jupiter.api.Test
class ProviderTableTest {
@Test
fun `paths follow the OpenTasks contract`() {
assertThat(ProviderTable.entries.map { it.path })
.containsExactly("tasklists", "tasks", "instances", "properties").inOrder()
}
@Test
fun `each table keys on its OpenTasks id column`() {
assertThat(ProviderTable.LISTS.idColumn).isEqualTo(Lists.ID)
assertThat(ProviderTable.TASKS.idColumn).isEqualTo(Tasks.ID)
assertThat(ProviderTable.INSTANCES.idColumn).isEqualTo(Tasks.ID)
assertThat(ProviderTable.PROPERTIES.idColumn).isEqualTo(Properties.PROPERTY_ID)
}
@Test
fun `no table declares a column twice`() {
ProviderTable.entries.forEach { table ->
assertThat(table.columns.map { it.first }).containsNoDuplicates()
}
}
@Test
fun `instances carry every task column`() {
val tasks = ProviderTable.TASKS.columns.map { it.first }
val instances = ProviderTable.INSTANCES.columns.map { it.first }
assertThat(instances).containsAtLeastElementsIn(tasks)
assertThat(instances).containsAtLeast(Instances.TASK_ID, Instances.INSTANCE_ORIGINAL_TIME)
}
@Test
fun `unsupported OpenTasks columns are declared so a projection naming them succeeds`() {
assertThat(ProviderTable.TASKS.columns.map { it.first }).containsAtLeast("_dirty", "_sync_id", "sync1", "pinned")
}
@Test
fun `a table URI keeps the caller's selection untouched`() {
assertThat(ProviderTable.TASKS.selectionFor(null, "title = ?")).isEqualTo("title = ?")
assertThat(ProviderTable.TASKS.selectionFor(null, null)).isNull()
}
@Test
fun `a row URI narrows to its id`() {
assertThat(ProviderTable.TASKS.selectionFor(4, null)).isEqualTo("_id = 4")
assertThat(ProviderTable.PROPERTIES.selectionFor(9, null)).isEqualTo("property_id = 9")
}
@Test
fun `a row URI parenthesises the caller's selection so an OR cannot widen it`() {
assertThat(ProviderTable.TASKS.selectionFor(4, "a = 1 OR b = 2")).isEqualTo("_id = 4 AND (a = 1 OR b = 2)")
}
@Test
fun `MIME types are OpenTasks' own`() {
assertThat(ProviderTable.TASKS.mimeType(single = false)).isEqualTo("vnd.android.cursor.dir/org.dmfs.tasks.tasks")
assertThat(ProviderTable.LISTS.mimeType(single = true))
.isEqualTo("vnd.android.cursor.item/org.dmfs.tasks.tasklists")
}
}
@@ -0,0 +1,490 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.tasks.TasksContract
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.domain.TaskStatus
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
import kotlin.time.Instant
class ProviderTaskWriterTest {
private val now = Instant.parse("2026-10-15T10:00:00Z")
private val earlier = Instant.parse("2026-10-01T10:00:00Z")
private val task = TaskEntity(id = 5, listId = 1, uid = "uid-5", title = "Old", lastModified = earlier)
private fun patch(current: TaskEntity = task, vararg values: Pair<String, Any?>) =
ProviderTaskWriter.patch(current, mapOf(*values), now, "Europe/Berlin")
private fun millis(at: Instant) = at.toEpochMilliseconds()
// --- column filtering ---------------------------------------------------------
@Test
fun `published read-only columns are dropped`() {
val values = mapOf(Tasks.TITLE to "t", Tasks.LIST_NAME to "x", Tasks.IS_CLOSED to 1, "_sync_id" to "s")
assertThat(ProviderTaskWriter.writable(ProviderTable.TASKS, values, WRITABLE_TASK_COLUMNS))
.containsExactly(Tasks.TITLE, "t")
}
@Test
fun `a column the table does not have is rejected`() {
val error = assertThrows<IllegalArgumentException> {
ProviderTaskWriter.writable(ProviderTable.TASKS, mapOf("href" to "x", "etag" to "y"), WRITABLE_TASK_COLUMNS)
}
assertThat(error).hasMessageThat().contains("etag, href")
}
@Test
fun `instances accept every task column and their own`() {
val values = mapOf<String, Any?>(Tasks.STATUS to 2, Instances.INSTANCE_START to 0L)
assertThat(ProviderTaskWriter.writable(ProviderTable.INSTANCES, values, WRITABLE_TASK_COLUMNS))
.containsExactly(Tasks.STATUS, 2)
}
// --- new tasks ----------------------------------------------------------------
@Test
fun `a new task needs a list`() {
assertThrows<IllegalArgumentException> {
ProviderTaskWriter.newTask(mapOf(Tasks.TITLE to "t"), "u", now, "UTC")
}
}
@Test
fun `a new task takes the client's uid or the minted one`() {
val minted = ProviderTaskWriter.newTask(mapOf(Tasks.LIST_ID to 3L), "minted", now, "UTC")
val own = ProviderTaskWriter.newTask(mapOf(Tasks.LIST_ID to 3L, Tasks.UID to " theirs "), "minted", now, "UTC")
assertThat(minted.uid).isEqualTo("minted")
assertThat(own.uid).isEqualTo("theirs")
assertThat(own.listId).isEqualTo(3L)
assertThat(own.createdAt).isEqualTo(now)
assertThat(own.lastModified).isEqualTo(now)
assertThat(own.isDirty).isTrue()
}
@Test
fun `a new task may have no title`() {
assertThat(ProviderTaskWriter.newTask(mapOf(Tasks.LIST_ID to 3L), "u", now, "UTC").title).isNull()
}
// --- content ------------------------------------------------------------------
@Test
fun `only the columns given change`() {
val current = task.copy(description = "keep", priority = 3, location = "home")
val edited = patch(current, Tasks.TITLE to " New ")
assertThat(edited.title).isEqualTo("New")
assertThat(edited.description).isEqualTo("keep")
assertThat(edited.priority).isEqualTo(3)
assertThat(edited.location).isEqualTo("home")
assertThat(edited.lastModified).isEqualTo(now)
assertThat(edited.isDirty).isTrue()
}
@Test
fun `blank text clears and null clears`() {
val current = task.copy(description = "d", url = "u")
val edited = patch(current, Tasks.DESCRIPTION to " ", Tasks.URL to null)
assertThat(edited.description).isNull()
assertThat(edited.url).isNull()
}
@Test
fun `numbers arrive in whatever shape the client sent`() {
val edited = patch(task, Tasks.PRIORITY to "2", Tasks.CLASSIFICATION to 1L, Tasks.SORTING to 7, Tasks.TASK_COLOR to -16711936)
assertThat(edited.priority).isEqualTo(2)
assertThat(edited.classification).isEqualTo(1)
assertThat(edited.sortOrder).isEqualTo(7)
assertThat(edited.color).isEqualTo(-16711936)
}
@Test
fun `a null priority is no priority`() {
assertThat(patch(task.copy(priority = 4), Tasks.PRIORITY to null).priority).isEqualTo(TasksContract.PRIORITY_NONE)
}
@Test
fun `out of range values are rejected`() {
assertThrows<IllegalArgumentException> { patch(task, Tasks.PRIORITY to 10) }
assertThrows<IllegalArgumentException> { patch(task, Tasks.CLASSIFICATION to 3) }
assertThrows<IllegalArgumentException> { patch(task, Tasks.PERCENT_COMPLETE to 101) }
assertThrows<IllegalArgumentException> { patch(task, Tasks.STATUS to 4) }
assertThrows<IllegalArgumentException> { patch(task, Tasks.PRIORITY to "high") }
}
@Test
fun `the uid cannot change`() {
assertThat(patch(task, Tasks.UID to "uid-5").uid).isEqualTo("uid-5")
assertThrows<IllegalArgumentException> { patch(task, Tasks.UID to "other") }
}
@Test
fun `a task cannot be its own parent`() {
assertThrows<IllegalArgumentException> { patch(task, Tasks.PARENT_ID to 5L) }
assertThat(patch(task, Tasks.PARENT_ID to 9L).parentId).isEqualTo(9L)
assertThat(patch(task.copy(parentId = 9), Tasks.PARENT_ID to null).parentId).isNull()
}
@Test
fun `a list can be changed but not cleared`() {
assertThat(patch(task, Tasks.LIST_ID to 2L).listId).isEqualTo(2L)
assertThrows<IllegalArgumentException> { patch(task, Tasks.LIST_ID to null) }
}
@Test
fun `sync state is left alone`() {
val synced = task.copy(href = "/t.ics", etag = "e1", sequence = 4, unknownProperties = "X-FOO:bar")
val edited = patch(synced, Tasks.TITLE to "t")
assertThat(edited.href).isEqualTo("/t.ics")
assertThat(edited.etag).isEqualTo("e1")
assertThat(edited.sequence).isEqualTo(4)
assertThat(edited.unknownProperties).isEqualTo("X-FOO:bar")
}
// --- times --------------------------------------------------------------------
@Test
fun `a timed task takes the device zone unless it has one`() {
val due = Instant.parse("2026-10-20T15:00:00Z")
assertThat(patch(task, Tasks.DUE to millis(due)).timezone).isEqualTo("Europe/Berlin")
assertThat(patch(task.copy(timezone = "UTC"), Tasks.DUE to millis(due)).timezone).isEqualTo("UTC")
assertThat(patch(task, Tasks.DUE to millis(due), Tasks.TZ to "America/New_York").timezone)
.isEqualTo("America/New_York")
}
@Test
fun `a new timed task without a zone is floating`() {
val due = millis(Instant.parse("2026-10-20T09:00:00Z"))
assertThat(ProviderTaskWriter.newTask(mapOf(Tasks.LIST_ID to 3L, Tasks.DUE to due), "u", now, "UTC").timezone).isNull()
assertThat(ProviderTaskWriter.newTask(mapOf(Tasks.LIST_ID to 3L, Tasks.DUE to due, Tasks.TZ to "Europe/Paris"), "u", now, "UTC").timezone)
.isEqualTo("Europe/Paris")
}
@Test
fun `an unknown zone is rejected`() {
assertThrows<IllegalArgumentException> { patch(task, Tasks.DUE to 0L, Tasks.TZ to "Mars/Olympus") }
}
@Test
fun `an all-day task is floored to UTC midnight and has no zone`() {
val due = Instant.parse("2026-10-20T15:00:00Z")
val edited = patch(task.copy(timezone = "UTC"), Tasks.DUE to millis(due), Tasks.IS_ALLDAY to 1)
assertThat(edited.isAllDay).isTrue()
assertThat(edited.due).isEqualTo(Instant.parse("2026-10-20T00:00:00Z"))
assertThat(edited.timezone).isNull()
}
@Test
fun `due before start is rejected`() {
assertThrows<IllegalArgumentException> {
patch(task, Tasks.DTSTART to millis(now), Tasks.DUE to millis(now - 1.hours))
}
}
@Test
fun `setting a due date drops a duration`() {
val edited = patch(task.copy(dtstart = now, duration = "PT1H"), Tasks.DUE to millis(now + 2.hours))
assertThat(edited.duration).isNull()
assertThat(edited.due).isEqualTo(now + 2.hours)
}
@Test
fun `due and duration together are rejected`() {
assertThrows<IllegalArgumentException> {
patch(task, Tasks.DTSTART to millis(now), Tasks.DUE to millis(now + 1.hours), Tasks.DURATION to "PT1H")
}
}
@Test
fun `a duration must be an iCalendar one`() {
assertThat(patch(task, Tasks.DTSTART to millis(now), Tasks.DURATION to "P1W").duration).isEqualTo("P1W")
assertThat(patch(task, Tasks.DTSTART to millis(now), Tasks.DURATION to "PT1H30M").duration).isEqualTo("PT1H30M")
assertThrows<IllegalArgumentException> { patch(task, Tasks.DURATION to "an hour") }
}
// --- recurrence ---------------------------------------------------------------
@Test
fun `a rule is stored without its property name`() {
assertThat(patch(task, Tasks.DUE to millis(now), Tasks.RRULE to "RRULE:FREQ=WEEKLY").rrule).isEqualTo("FREQ=WEEKLY")
}
@Test
fun `a malformed rule is rejected`() {
assertThrows<IllegalArgumentException> { patch(task, Tasks.DUE to millis(now), Tasks.RRULE to "FREQ=SOMETIMES") }
}
@Test
fun `a rule needs a date`() {
assertThrows<IllegalArgumentException> { patch(task, Tasks.RRULE to "FREQ=DAILY") }
}
@Test
fun `dropping the rule drops its exceptions`() {
val series = task.copy(due = now, rrule = "FREQ=DAILY", exdate = "20261016T100000Z", rdate = "20261030T100000Z")
val edited = patch(series, Tasks.RRULE to null)
assertThat(edited.rrule).isNull()
assertThat(edited.exdate).isNull()
assertThat(edited.rdate).isNull()
}
@Test
fun `an override carries no rule and stays in its series`() {
val override = task.copy(masterId = 4, recurrenceId = now, parentId = 2)
assertThrows<IllegalArgumentException> { patch(override, Tasks.RRULE to "FREQ=DAILY") }
assertThat(patch(override, Tasks.RRULE to null, Tasks.RDATE to null, Tasks.EXDATE to "").rrule).isNull()
assertThrows<IllegalArgumentException> { patch(override, Tasks.LIST_ID to 2L) }
assertThrows<IllegalArgumentException> { patch(override, Tasks.PARENT_ID to 3L) }
assertThat(patch(override, Tasks.LIST_ID to 1L, Tasks.PARENT_ID to 2L, Tasks.TITLE to "x").title).isEqualTo("x")
}
// --- completion ---------------------------------------------------------------
@Test
fun `completing sets progress and time`() {
val edited = patch(task, Tasks.STATUS to TasksContract.STATUS_COMPLETED)
assertThat(edited.status).isEqualTo(TaskStatus.COMPLETED)
assertThat(edited.percentComplete).isEqualTo(100)
assertThat(edited.completedAt).isEqualTo(now)
}
@Test
fun `completing keeps the client's completion time`() {
val edited = patch(task, Tasks.STATUS to TasksContract.STATUS_COMPLETED, Tasks.COMPLETED to millis(earlier))
assertThat(edited.completedAt).isEqualTo(earlier)
}
@Test
fun `reopening clears progress and time`() {
val done = task.copy(status = TaskStatus.COMPLETED, percentComplete = 100, completedAt = earlier)
val edited = patch(done, Tasks.STATUS to TasksContract.STATUS_NEEDS_ACTION)
assertThat(edited.status).isEqualTo(TaskStatus.NEEDS_ACTION)
assertThat(edited.percentComplete).isNull()
assertThat(edited.completedAt).isNull()
}
@Test
fun `cancelling keeps progress and clears the completion time`() {
val edited = patch(task.copy(percentComplete = 40, completedAt = earlier), Tasks.STATUS to TasksContract.STATUS_CANCELLED)
assertThat(edited.status).isEqualTo(TaskStatus.CANCELLED)
assertThat(edited.percentComplete).isEqualTo(40)
assertThat(edited.completedAt).isNull()
}
@Test
fun `progress implies a status`() {
assertThat(patch(task, Tasks.PERCENT_COMPLETE to 50).status).isEqualTo(TaskStatus.IN_PROCESS)
assertThat(patch(task, Tasks.PERCENT_COMPLETE to 0).status).isEqualTo(TaskStatus.NEEDS_ACTION)
val done = patch(task, Tasks.PERCENT_COMPLETE to 100)
assertThat(done.status).isEqualTo(TaskStatus.COMPLETED)
assertThat(done.completedAt).isEqualTo(now)
}
@Test
fun `progress below 100 reopens a completed task`() {
val done = task.copy(status = TaskStatus.COMPLETED, percentComplete = 100, completedAt = earlier)
val edited = patch(done, Tasks.PERCENT_COMPLETE to 60)
assertThat(edited.status).isEqualTo(TaskStatus.IN_PROCESS)
assertThat(edited.completedAt).isNull()
}
@Test
fun `a completion time alone completes and clearing it reopens`() {
val done = patch(task, Tasks.COMPLETED to millis(earlier))
assertThat(done.status).isEqualTo(TaskStatus.COMPLETED)
assertThat(done.percentComplete).isEqualTo(100)
assertThat(done.completedAt).isEqualTo(earlier)
val reopened = patch(done, Tasks.COMPLETED to null)
assertThat(reopened.status).isEqualTo(TaskStatus.NEEDS_ACTION)
assertThat(reopened.completedAt).isNull()
}
@Test
fun `a completed row written back with status open reopens it fully`() {
val done = task.copy(status = TaskStatus.COMPLETED, percentComplete = 100, completedAt = earlier)
val edited = patch(done, Tasks.STATUS to 0, Tasks.PERCENT_COMPLETE to 100, Tasks.COMPLETED to millis(earlier))
assertThat(edited.status).isEqualTo(TaskStatus.NEEDS_ACTION)
assertThat(edited.percentComplete).isNull()
assertThat(edited.completedAt).isNull()
}
@Test
fun `the changed completion column decides over unchanged ones written back`() {
val completed = patch(task, Tasks.STATUS to 0, Tasks.PERCENT_COMPLETE to 100, Tasks.COMPLETED to null)
assertThat(completed.status).isEqualTo(TaskStatus.COMPLETED)
assertThat(completed.completedAt).isEqualTo(now)
val done = task.copy(status = TaskStatus.COMPLETED, percentComplete = 100, completedAt = earlier)
val reopened = patch(done, Tasks.STATUS to 2, Tasks.PERCENT_COMPLETE to 100, Tasks.COMPLETED to null)
assertThat(reopened.status).isEqualTo(TaskStatus.NEEDS_ACTION)
assertThat(reopened.percentComplete).isNull()
}
@Test
fun `a write without completion columns leaves completion alone`() {
val done = task.copy(status = TaskStatus.COMPLETED, percentComplete = 100, completedAt = earlier)
val edited = patch(done, Tasks.TITLE to "t")
assertThat(edited.status).isEqualTo(TaskStatus.COMPLETED)
assertThat(edited.completedAt).isEqualTo(earlier)
}
// --- occurrences --------------------------------------------------------------
@Test
fun `an occurrence is completed, cancelled or reopened`() {
assertThat(ProviderTaskWriter.occurrenceChange(mapOf(Tasks.STATUS to 2))).isEqualTo(OccurrenceChange.COMPLETE)
assertThat(ProviderTaskWriter.occurrenceChange(mapOf(Tasks.STATUS to 3))).isEqualTo(OccurrenceChange.CANCEL)
assertThat(ProviderTaskWriter.occurrenceChange(mapOf(Tasks.STATUS to 0))).isEqualTo(OccurrenceChange.REOPEN)
assertThat(ProviderTaskWriter.occurrenceChange(mapOf(Tasks.PERCENT_COMPLETE to 100))).isEqualTo(OccurrenceChange.COMPLETE)
assertThat(ProviderTaskWriter.occurrenceChange(mapOf(Tasks.PERCENT_COMPLETE to 0))).isEqualTo(OccurrenceChange.REOPEN)
assertThat(ProviderTaskWriter.occurrenceChange(mapOf(Tasks.COMPLETED to 1L))).isEqualTo(OccurrenceChange.COMPLETE)
assertThat(ProviderTaskWriter.occurrenceChange(mapOf(Tasks.COMPLETED to null))).isEqualTo(OccurrenceChange.REOPEN)
}
@Test
fun `status wins over progress on an occurrence`() {
assertThat(ProviderTaskWriter.occurrenceChange(mapOf(Tasks.STATUS to 2, Tasks.PERCENT_COMPLETE to 100)))
.isEqualTo(OccurrenceChange.COMPLETE)
}
@Test
fun `an occurrence has no partial progress`() {
assertThrows<IllegalArgumentException> { ProviderTaskWriter.occurrenceChange(mapOf(Tasks.STATUS to 1)) }
assertThrows<IllegalArgumentException> { ProviderTaskWriter.occurrenceChange(mapOf(Tasks.PERCENT_COMPLETE to 50)) }
}
@Test
fun `values without completion say nothing about the occurrence`() {
assertThat(ProviderTaskWriter.occurrenceChange(mapOf(Tasks.TITLE to "t"))).isNull()
}
@Test
fun `flags accept the shapes clients send`() {
listOf(1, 1L, true, "1", "true").forEach {
assertThat(patch(task, Tasks.DUE to millis(now + 1.days), Tasks.IS_ALLDAY to it).isAllDay).isTrue()
}
listOf(0, false, "0", null).forEach {
assertThat(patch(task.copy(isAllDay = true), Tasks.IS_ALLDAY to it).isAllDay).isFalse()
}
assertThrows<IllegalArgumentException> { patch(task, Tasks.IS_ALLDAY to "yes") }
}
// --- no-op writes -------------------------------------------------------------
@Test
fun `a write that changes nothing returns the row as it was`() {
val current = task.copy(description = "d", priority = 3, isDirty = false)
assertThat(patch(current)).isSameInstanceAs(current)
assertThat(patch(current, Tasks.TITLE to "Old", Tasks.PRIORITY to 3L, Tasks.DESCRIPTION to "d"))
.isSameInstanceAs(current)
assertThat(patch(current, Tasks.TITLE to "New").isDirty).isTrue()
}
@Test
fun `only columns that differ from the row count as changed`() {
val due = Instant.parse("2026-10-20T15:00:00Z")
val series = task.copy(due = due, timezone = "UTC", rrule = "FREQ=DAILY", status = TaskStatus.IN_PROCESS, percentComplete = 50)
val readBack = mapOf(
Tasks.TITLE to "Old", Tasks.DUE to millis(due), Tasks.DTSTART to null, Tasks.TZ to "UTC", Tasks.IS_ALLDAY to 0,
Tasks.RRULE to "FREQ=DAILY", Tasks.RDATE to null, Tasks.EXDATE to null, Tasks.DURATION to null,
Tasks.STATUS to 1, Tasks.PERCENT_COMPLETE to 50, Tasks.COMPLETED to null,
)
assertThat(ProviderTaskWriter.changedColumns(series, readBack, "Europe/Berlin")).isEmpty()
assertThat(
ProviderTaskWriter.changedColumns(
series,
readBack + (Tasks.DUE to millis(due + 1.hours)) + (Tasks.TITLE to "New") + (Tasks.STATUS to 2),
"Europe/Berlin",
),
).containsExactly(Tasks.DUE, Tasks.TITLE, Tasks.STATUS)
}
@Test
fun `a value that does not fit counts as changed`() {
assertThat(ProviderTaskWriter.changedColumns(task, mapOf(Tasks.PRIORITY to 99), "UTC")).containsExactly(Tasks.PRIORITY)
}
@Test
fun `a floating task stays floating`() {
val floating = task.copy(due = now, timezone = null)
assertThat(patch(floating, Tasks.TITLE to "t").timezone).isNull()
assertThat(patch(floating, Tasks.DUE to millis(now + 1.hours)).timezone).isNull()
assertThat(patch(floating, Tasks.TITLE to "Old")).isSameInstanceAs(floating)
}
@Test
fun `an all-day task made timed takes the device zone`() {
val allDay = task.copy(due = Instant.parse("2026-10-20T00:00:00Z"), isAllDay = true)
val timed = patch(allDay, Tasks.IS_ALLDAY to 0, Tasks.DUE to millis(Instant.parse("2026-10-20T15:00:00Z")))
assertThat(timed.timezone).isEqualTo("Europe/Berlin")
}
@Test
fun `clearing every date clears the zone`() {
assertThat(patch(task.copy(due = now, timezone = "UTC"), Tasks.DUE to null).timezone).isNull()
}
@Test
fun `exception and extra dates must be iCalendar values of one kind`() {
val series = task.copy(due = now, rrule = "FREQ=DAILY", timezone = "UTC")
assertThat(patch(series, Tasks.EXDATE to "20261016T100000Z, 20261017T100000Z").exdate)
.isEqualTo("20261016T100000Z,20261017T100000Z")
assertThat(patch(series, Tasks.RDATE to "20261030").rdate).isEqualTo("20261030")
assertThrows<IllegalArgumentException> { patch(series, Tasks.EXDATE to "tomorrow") }
assertThrows<IllegalArgumentException> { patch(series, Tasks.EXDATE to "20261016,20261017T100000Z") }
assertThrows<IllegalArgumentException> { patch(series, Tasks.RDATE to "20261016T100000Z,20261017T100000") }
}
@Test
fun `stored dates written back as synced are kept`() {
val series = task.copy(due = now, rrule = "FREQ=DAILY", rdate = "20261016T100000Z/PT1H", exdate = "20261016,20261017T100000Z")
val edited = patch(series, Tasks.TITLE to "New", Tasks.RDATE to series.rdate, Tasks.EXDATE to series.exdate)
assertThat(edited.rdate).isEqualTo(series.rdate)
assertThat(edited.exdate).isEqualTo(series.exdate)
assertThat(ProviderTaskWriter.changedColumns(series, mapOf(Tasks.RDATE to series.rdate), "UTC")).isEmpty()
}
}
@@ -0,0 +1,35 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class CategoriesLiftTest {
@Test
fun `claims a lone plain line and keeps the rest of the residue`() {
val result = CategoriesLift.lift("X-FOO:bar\r\nCATEGORIES:Work,Home\r\n")
assertThat(result.categories).containsExactly("Work", "Home").inOrder()
assertThat(result.claimed).isTrue()
assertThat(result.residue).contains("X-FOO:bar")
assertThat(result.residue).doesNotContain("CATEGORIES")
}
@Test
fun `a residue that was only the line becomes null`() {
assertThat(CategoriesLift.lift("CATEGORIES:Work\r\n").residue).isNull()
}
@Test
fun `several lines are read but not claimed`() {
val residue = "CATEGORIES:a\r\nCATEGORIES:b\r\n"
val result = CategoriesLift.lift(residue)
assertThat(result.categories).containsExactly("a", "b").inOrder()
assertThat(result.claimed).isFalse()
assertThat(result.residue).isEqualTo(residue)
}
@Test
fun `nothing to lift from an empty residue`() {
assertThat(CategoriesLift.lift(null)).isEqualTo(CategoriesLift.Result(emptyList(), null, claimed = false))
}
}
@@ -50,4 +50,12 @@ class ConvertersTest {
}
assertThat(Converters.alarmReferenceFrom("NONSENSE")).isEqualTo(AlarmReference.DUE)
}
@Test
fun `categories round-trip with escaped commas and come back cleaned`() {
val stored = CategoriesConverter.toText(listOf("a,b", " c ", "", "c"))
assertThat(stored).isEqualTo("a\\,b,c")
assertThat(CategoriesConverter.fromText(stored)).containsExactly("a,b", "c").inOrder()
assertThat(CategoriesConverter.fromText("")).isEmpty()
}
}
@@ -29,6 +29,7 @@ private fun form(
start: Instant? = null,
due: Instant? = null,
isAllDay: Boolean = false,
tags: List<String> = emptyList(),
) = TaskForm(
title = title,
listId = 1,
@@ -36,6 +37,7 @@ private fun form(
start = start,
due = due,
isAllDay = isAllDay,
tags = tags,
)
class TaskFormWriterTest {
@@ -51,6 +53,12 @@ class TaskFormWriterTest {
assertThat(entity.isDirty).isTrue()
}
@Test
fun `tags are trimmed and blanks dropped`() {
val entity = TaskFormWriter.apply(task(), form(tags = listOf(" Work ", "", " ", "Home")), NOW, ZONE)
assertThat(entity.categories).containsExactly("Work", "Home").inOrder()
}
@Test
fun `progress and status move together in both directions`() {
assertThat(TaskFormWriter.apply(task(), form(percentComplete = 100), NOW, ZONE).status)
@@ -0,0 +1,119 @@
package de.jeanlucmakiola.agendula.domain
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import java.time.LocalDate
import java.time.ZoneId
import kotlin.time.Instant
class SavedFilterTest {
private val dayMs = 86_400_000L
private val todayStart = Instant.fromEpochMilliseconds(1000 * dayMs)
private val todayEnd = Instant.fromEpochMilliseconds(1001 * dayMs)
private val overdue = Instant.fromEpochMilliseconds(999 * dayMs)
private val today = Instant.fromEpochMilliseconds(1000 * dayMs + 1)
private val upcoming = Instant.fromEpochMilliseconds(1002 * dayMs)
private fun SavedFilter.matches(task: Task) = matches(task, todayStart, todayEnd)
private val empty = SavedFilter(id = 1, name = "f")
@Test
fun `an empty filter is every open task`() {
assertThat(empty.matches(testTask(due = null))).isTrue()
assertThat(empty.matches(testTask(due = overdue))).isTrue()
assertThat(empty.matches(testTask(status = TaskStatus.COMPLETED))).isFalse()
assertThat(empty.matches(testTask(status = TaskStatus.CANCELLED))).isFalse()
}
@Test
fun `today and overdue together`() {
val filter = empty.copy(due = setOf(DueWindow.TODAY, DueWindow.OVERDUE))
assertThat(filter.matches(testTask(due = overdue))).isTrue()
assertThat(filter.matches(testTask(due = today))).isTrue()
assertThat(filter.matches(testTask(due = upcoming))).isFalse()
assertThat(filter.matches(testTask(due = null))).isFalse()
}
@Test
fun `lists narrow by membership`() {
val filter = empty.copy(listIds = setOf(2L, 3L))
assertThat(filter.matches(testTask(listId = 2))).isTrue()
assertThat(filter.matches(testTask(listId = 1))).isFalse()
}
@Test
fun `lists from another store match nothing here`() {
val filter = empty.copy(listsElsewhere = true)
assertThat(filter.matches(testTask(listId = 1))).isFalse()
assertThat(filter.copy(listIds = setOf(1L)).matches(testTask(listId = 1))).isTrue()
}
@Test
fun `priority is a floor`() {
val filter = empty.copy(minPriority = Priority.MEDIUM)
assertThat(filter.matches(testTask(priority = Priority.HIGH))).isTrue()
assertThat(filter.matches(testTask(priority = Priority.MEDIUM))).isTrue()
assertThat(filter.matches(testTask(priority = Priority.LOW))).isFalse()
assertThat(filter.matches(testTask(priority = Priority.NONE))).isFalse()
}
@Test
fun `tags match any or all`() {
val any = empty.copy(tags = setOf("work", "home"))
val all = any.copy(tagMatch = TagMatch.ALL)
val one = testTask(tags = listOf("work"))
val both = testTask(tags = listOf("home", "work", "x"))
assertThat(any.matches(one)).isTrue()
assertThat(any.matches(testTask(tags = listOf("x")))).isFalse()
assertThat(all.matches(one)).isFalse()
assertThat(all.matches(both)).isTrue()
}
@Test
fun `criteria combine with and`() {
val filter = empty.copy(due = setOf(DueWindow.TODAY), listIds = setOf(1L), tags = setOf("work"))
assertThat(filter.matches(testTask(listId = 1, due = today, tags = listOf("work")))).isTrue()
assertThat(filter.matches(testTask(listId = 2, due = today, tags = listOf("work")))).isFalse()
assertThat(filter.matches(testTask(listId = 1, due = today))).isFalse()
}
@Test
fun `an all-day due is read as its date west of utc`() {
val zone = ZoneId.of("America/New_York")
val date = LocalDate.of(2026, 10, 10)
val start = Instant.fromEpochMilliseconds(date.atStartOfDay(zone).toInstant().toEpochMilli())
val end = Instant.fromEpochMilliseconds(date.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli())
fun window(d: LocalDate) = dueWindowOf(testTask(due = allDayInstantOf(d), isAllDay = true), start, end, zone)
assertThat(window(date)).isEqualTo(DueWindow.TODAY)
assertThat(window(date.plusDays(1))).isEqualTo(DueWindow.UPCOMING)
assertThat(window(date.minusDays(1))).isEqualTo(DueWindow.OVERDUE)
}
@Test
fun `a new task preset lands in its filter`() {
val day = LocalDate.of(2026, 10, 10)
val filter = empty.copy(
due = setOf(DueWindow.UPCOMING),
tags = setOf("work", "home"),
tagMatch = TagMatch.ALL,
minPriority = Priority.HIGH,
)
val preset = filter.newTaskPreset(day)
assertThat(preset.dueDate).isEqualTo(day.plusDays(1))
assertThat(preset.tags).containsExactly("home", "work")
assertThat(preset.priority).isEqualTo(Priority.HIGH)
assertThat(empty.copy(due = setOf(DueWindow.TODAY, DueWindow.UPCOMING)).newTaskPreset(day).dueDate).isEqualTo(day)
assertThat(empty.copy(due = setOf(DueWindow.TODAY, DueWindow.NO_DATE)).newTaskPreset(day).dueDate).isNull()
assertThat(empty.copy(tags = setOf("b", "a")).newTaskPreset(day).tags).containsExactly("a")
}
@Test
fun `lists since deleted drop out`() {
val filter = empty.copy(listIds = setOf(1L, 5L))
assertThat(filter.withListsIn(setOf(1L, 2L)).listIds).containsExactly(1L)
assertThat(filter.withListsIn(setOf(2L)).anyList).isTrue()
assertThat(filter.copy(listsElsewhere = true).withListsIn(setOf(2L)).anyList).isFalse()
}
}
@@ -58,4 +58,32 @@ class TaskFilteringTest {
assertThat(TaskFiltering.matches(task, TaskFilter.OfList(7), todayStart, todayEnd)).isTrue()
assertThat(TaskFiltering.matches(task, TaskFilter.OfList(8), todayStart, todayEnd)).isFalse()
}
@Test
fun `Tagged matches open and completed tasks carrying the tag`() {
val filter = TaskFilter.Tagged("Work")
assertThat(TaskFiltering.matches(testTask(tags = listOf("Home", "Work")), filter, todayStart, todayEnd)).isTrue()
assertThat(
TaskFiltering.matches(testTask(tags = listOf("Work"), status = TaskStatus.COMPLETED), filter, todayStart, todayEnd),
).isTrue()
assertThat(TaskFiltering.matches(testTask(tags = listOf("work")), filter, todayStart, todayEnd)).isFalse()
}
@Test
fun `narrowing by tags needs every one of them`() {
val task = testTask(tags = listOf("Home", "Work"))
assertThat(TaskFiltering.hasAllTags(task, emptySet())).isTrue()
assertThat(TaskFiltering.hasAllTags(task, setOf("Work"))).isTrue()
assertThat(TaskFiltering.hasAllTags(task, setOf("Work", "Home"))).isTrue()
assertThat(TaskFiltering.hasAllTags(task, setOf("Work", "Errands"))).isFalse()
}
@Test
fun `tags in use are listed once each, alphabetically ignoring case`() {
val tasks = listOf(
testTask(id = 1, tags = listOf("work", "Home")),
testTask(id = 2, tags = listOf("Home", "errands")),
)
assertThat(tagsInUse(tasks)).containsExactly("errands", "Home", "work").inOrder()
}
}
@@ -44,4 +44,16 @@ class TaskFormTest {
assertThat(TaskForm(title = "x", listId = 1, percentComplete = 30, parentId = 9).populatedFields())
.containsExactly(TaskFormField.Progress, TaskFormField.Parent)
}
@Test
fun `tags auto-reveal when the task has any`() {
assertThat(TaskForm(title = "x", listId = 1, tags = listOf("Work")).populatedFields())
.containsExactly(TaskFormField.Tags)
}
@Test
fun `tags are normalised to trimmed, non-blank, unique spellings in order`() {
assertThat(normalizeTags(listOf(" Work", "", "Home ", "Work", "work")))
.containsExactly("Work", "Home", "work").inOrder()
}
}
@@ -13,6 +13,7 @@ fun testTask(
isAllDay: Boolean = false,
completedAt: Instant? = null,
parentId: Long? = null,
tags: List<String> = emptyList(),
): Task = Task(
taskId = id,
listId = listId,
@@ -38,4 +39,5 @@ fun testTask(
distanceFromCurrent = 0,
created = null,
lastModified = null,
tags = tags,
)
@@ -35,9 +35,10 @@ class ICalendarWriterTest {
rrule: String? = null,
rdate: String? = null,
parentId: Long? = null,
tags: List<String> = emptyList(),
) = ExportTask(
taskId, uid, title, description, location, url, priority, status, percentComplete,
start, due, isAllDay, completedAt, created, lastModified, rrule, rdate, parentId,
start, due, isAllDay, completedAt, created, lastModified, rrule, rdate, parentId, tags,
)
private fun write(vararg tasks: ExportTask, name: String = "Groceries"): String =
@@ -59,6 +60,13 @@ class ICalendarWriterTest {
assertThat(lines).containsAtLeast("VERSION:2.0", "BEGIN:VTODO", "END:VTODO")
}
@Test
fun `writes the tags as one CATEGORIES line, escaped`() {
val lines = linesOf(write(task(tags = listOf("Work", "Home, Garden"))))
assertThat(lines).contains("CATEGORIES:Work,Home\\, Garden")
assertThat(linesOf(write(task())).none { it.startsWith("CATEGORIES") }).isTrue()
}
@Test
fun `uses CRLF line endings`() {
// RFC 5545 requires CRLF. Bare LF is the classic way an .ics is rejected
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.agendula.ui.navigation
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.domain.TaskFilter
import org.junit.jupiter.api.Test
class DestinationsTest {
@@ -17,4 +18,13 @@ class DestinationsTest {
assertThat(Dest.Settings.build()).isEqualTo(Dest.SETTINGS)
assertThat(Dest.Settings.build(Dest.Settings.SECTION_ACCOUNTS)).isEqualTo("settings?section=Accounts")
}
@Test
fun `a saved filter routes by id and comes back from its arguments`() {
assertThat(Dest.TaskList.build(TaskFilter.Saved(5))).isEqualTo("taskList?filterId=5")
assertThat(Dest.TaskList.filterOf(smart = null, listId = Dest.NO_ID, filterId = 5)).isEqualTo(TaskFilter.Saved(5))
assertThat(Dest.FilterEdit.build()).isEqualTo("filterEdit")
assertThat(Dest.FilterEdit.build(5)).isEqualTo("filterEdit?filterId=5")
assertThat(Dest.TaskEdit.buildNew(filterId = 5)).isEqualTo("taskEdit?filterId=5")
}
}
@@ -32,7 +32,8 @@ class WidgetDataTest {
fun `every offered filter round-trips through storage`() {
val filters = WIDGET_SMART_LISTS.map { TaskFilter.Smart(it) } +
TaskFilter.OfList(42) +
TaskFilter.OfLists(setOf(1L, 2L, 3L))
TaskFilter.OfLists(setOf(1L, 2L, 3L)) +
TaskFilter.Saved(7)
filters.forEach { assertThat(parseWidgetFilter(it.widgetStorageValue())).isEqualTo(it) }
}
@@ -45,7 +46,7 @@ class WidgetDataTest {
fun `garbage and smart lists the widget does not offer fall back to Today`() {
listOf(
"", "list:", "list:-3", "list:abc", "lists:", "lists:abc", "lists:-1,-2",
"smart:COMPLETED", "smart:nope", "whatever",
"smart:COMPLETED", "smart:nope", "filter:", "filter:0", "filter:x", "whatever",
).forEach {
assertThat(parseWidgetFilter(it)).isEqualTo(DEFAULT_WIDGET_FILTER)
}
+2
View File
@@ -30,3 +30,5 @@ VTIMEZONE bodies, fold positions, parameter quoting).
| `malformed-scalars.ics` | `PRIORITY:11`, `PERCENT-COMPLETE:150`, `SEQUENCE:x`, `STATUS:X-DEFERRED`, `CLASS:X-INTERNAL` — values no spec allows. Clamping or defaulting any of them is a silent rewrite of somebody's data |
| `two-timezones.ics` | `DTSTART` and `DUE` in different zones. One `timezone` column cannot author both |
| `folded-utf8.ics` | A line folded mid-emoji-adjacent, to prove folding counts octets and splits on character boundaries |
| `categories-shapes.ics` | One `CATEGORIES` line with an escaped comma inside a value — the one shape the column claims, so it is re-authored and must come out identical |
| `categories-split.ics` | One line per tag, a `LANGUAGE` parameter and a space after a comma. Read for display, but none of it is ours to rewrite |
@@ -0,0 +1,10 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Several Clients//EN
BEGIN:VTODO
UID:categories-001
DTSTAMP:20260901T120000Z
SUMMARY:One line, an escaped comma
CATEGORIES:Work,Home\, Garden
END:VTODO
END:VCALENDAR
@@ -0,0 +1,12 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Several Clients//EN
BEGIN:VTODO
UID:categories-002
DTSTAMP:20260901T120000Z
SUMMARY:One line per tag, one with a language
CATEGORIES:Errands
CATEGORIES;LANGUAGE=de:Einkauf
CATEGORIES:a, b
END:VTODO
END:VCALENDAR
+119
View File
@@ -0,0 +1,119 @@
# Using Agendula's tasks from another app
When Agendula keeps tasks in its own database (the default storage mode), it
publishes them through a `ContentProvider` that follows the
[OpenTasks contract](https://github.com/dmfs/opentasks/blob/master/opentasks-contract/src/main/java/org/dmfs/tasks/contract/TaskContract.java).
An app that already works with OpenTasks or tasks.org can work with Agendula by
using a different authority and permissions.
| | |
|---|---|
| Authority | `de.jeanlucmakiola.agendula.tasks` (offline build: `de.jeanlucmakiola.agendula.offline.tasks`) |
| Read permission | `de.jeanlucmakiola.agendula.permission.READ_TASKS` (offline build: `de.jeanlucmakiola.agendula.offline.permission.READ_TASKS`) |
| Write permission | `de.jeanlucmakiola.agendula.permission.WRITE_TASKS` (offline build: `de.jeanlucmakiola.agendula.offline.permission.WRITE_TASKS`) |
| Protection level | `dangerous`: request them at runtime |
| Package visibility | add `<provider android:authorities="de.jeanlucmakiola.agendula.tasks" />` to your `<queries>` |
Both names are derived from the installed package name, so every build has its
own and they can be installed side by side: the authority is
`<package>.tasks` and the permissions are `<package>.permission.READ_TASKS` and
`<package>.permission.WRITE_TASKS`.
| Build | Package |
|---|---|
| Release | `de.jeanlucmakiola.agendula` |
| Release, offline | `de.jeanlucmakiola.agendula.offline` |
| Debug | `de.jeanlucmakiola.agendula.debug` |
| Debug, offline | `de.jeanlucmakiola.agendula.offline.debug` |
| Release test | `de.jeanlucmakiola.agendula.releasetest` |
| Release test, offline | `de.jeanlucmakiola.agendula.offline.releasetest` |
## Tables
| URI | Rows |
|---|---|
| `content://<authority>/tasklists` | one per list |
| `content://<authority>/tasks` | one per task; a modified occurrence of a recurring task is its own row with `original_instance_id` / `original_instance_time` set |
| `content://<authority>/instances` | one per occurrence: recurring tasks are expanded up to one year back and two years ahead, and modified occurrences replace the ones they override |
| `content://<authority>/properties` | reminders (`vnd.android.cursor.item/alarm`) and tags (`vnd.android.cursor.item/category`) |
Each table also accepts `/<id>`. Column names, value encodings (epoch millis,
all-day dates as UTC midnight, `status`, `priority`, `class`) and MIME types
are the OpenTasks ones. `selection`, `selectionArgs` and `sortOrder` work as
they do against OpenTasks.
## Writing
Writes need the write permission and go through the same code as edits made in
Agendula itself: a changed task is marked for the next CalDAV sync, reminders
are re-armed, and widgets refresh.
| Table | insert | update | delete |
|---|---|---|---|
| `tasklists` | no | no | no |
| `tasks` | yes | yes | yes |
| `instances` | no | see below | one occurrence |
| `properties` | alarms and tags | alarms and tags | alarms and tags |
`update` and `delete` take a `selection` like a query does, and it matches the
rows you can read. `insert` returns the new row's URI. `applyBatch` runs in one
transaction: if any operation fails, none of them is applied. A write that
changes nothing does not mark the task for sync.
**Columns.** A task write may set `list_id`, `_uid` (on insert only), `title`,
`description`, `location`, `url`, `priority`, `class`, `status`,
`percent_complete`, `completed`, `task_color`, `dtstart`, `due`, `duration`,
`is_allday`, `tz`, `rrule`, `rdate`, `exdate`, `parent_id` and `sorting`. Other
columns the table publishes are ignored, so you can write back a row you read.
A column the table does not have is rejected with `IllegalArgumentException`.
Agendula's sync state (the server's URL and ETag, the iCalendar `SEQUENCE`, and
properties it does not understand) is not published and cannot be written. A
timed task without a `tz` is floating and stays floating. A task inserted
without a `tz` is floating, as in OpenTasks; an existing task that first gets a
date takes the device's zone. `rdate` and `exdate` take comma-separated
iCalendar values of one kind: all dates, all UTC date-times or all local
date-times. A value written back exactly as read is kept, whatever its shape.
**Status.** `status`, `percent_complete` and `completed` stay consistent the way
OpenTasks keeps them. Completing a task sets 100 % and a completion time,
progress between 1 and 99 sets "in process", and a completion time alone
completes the task. When you write back a row you read, the column you changed
decides; the others it carries unchanged do not.
**Recurring tasks.** Writing `status`, `percent_complete` or `completed` to an
`instances` row completes, cancels or reopens that one occurrence; partial
progress is rejected. Other content columns written to an instance change the
whole series, and that occurrence too if it had been modified on its own.
Changing a time or recurrence column (`dtstart`, `due`, `duration`,
`is_allday`, `tz`, `rrule`, `rdate`, `exdate`) is rejected on an instance of a
recurring task, so move a series through `tasks`; writing those columns back
unchanged, as in a row you read, is fine. Moving a series' start or changing its
rule drops the occurrences that had been modified on their own, as it does in
the app. Deleting an `instances` row deletes that one occurrence.
**Subtasks.** A subtask has to be in its parent's list. Moving a task to another
list moves its subtasks with it, and deleting a task deletes them. A task in a
synced list stays as a hidden tombstone until the next sync removes it from the
server.
**Rejected writes** throw `IllegalArgumentException` for a bad value, a missing
list or parent, or a parent in another list. They throw
`UnsupportedOperationException` for lists and for tasks in a list the server
has made read-only, and `IllegalStateException` while Agendula works on top of
an external provider or is still starting. `list_access_level` does not tell
you which lists are read-only: OpenTasks reserves it and asks providers to
publish `0`.
## Behaviour
- **Change notifications.** Register a `ContentObserver` on
`content://<authority>` with `notifyForDescendants = true`. It fires when
tasks change or when the user switches storage mode.
- **External storage mode.** When the user has Agendula work on top of
OpenTasks or tasks.org, every table is empty and writes are refused. Use that
provider directly.
- **Ids are stable.** A task keeps its `_id`. An instance's `_id` is built
from its series and its original time, and a property's from its alarm or
its task and tag, so they stay the same across reads and within a batch for
as long as that occurrence, alarm or tag exists. `distance_from_current`
still moves with the clock, and observers are notified hourly when it does.

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