Compare commits

..
Author SHA1 Message Date
makiolaj 44d6038c8b chore: 1.2.0-beta.1
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Successful in 14m27s
2026-10-21 18:46:51 +02:00
Jean-Luc Makiolaandmakiolaj c2826d3ab6 fix: drop the duplicate TaskAlarmDao.update (#89)
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
Merging #88 left two `@Update fun update(alarm: TaskAlarmEntity)` declarations in `TaskAlarmDao`: one returning `Int` from the release branch, and one returning `Unit` from the feature branch. `release/v1.2.0` doesn't compile with both. This keeps the `Int` one, which `ProviderWriter` uses.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/89
2026-10-21 18:43:30 +02:00
7c943f4831 Start-based reminders and timed all-day reminders (#72, #78) (#88)
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
Reminders can count from a task's start as well as its due date (#72), and each reminder on an all-day task can have its own time (#78).

## Changes
- A reminder counts from the start or the due date. Tasks with only a start date get reminders. Defaults in Settings can mix both, globally and per list.
- Each reminder on an all-day task can have its own time of day. Reminders without one ring at the all-day time set in Settings. 00:00 can't be stored, so it becomes 00:01.
- The editor shows the defaults a new task, or a newly added date, will get, and keeps them in step with dates, list and all-day until you change a reminder. What it shows is what gets saved. Removing the shown defaults now leaves the task without a reminder (Room store only; OpenTasks mode still falls back to the default).
- Switching between timed and all-day converts the reminders right away in the editor. Switching straight back restores them.
- Labels name the date when a task has both: "15 minutes before the start", "On the due date at 09:00". New reminders preselect the start.
- Defaults that have already passed don't ring when the task is saved.
- Sync: reminders rounded to whole days by builds before 1.2 pick up their real time of day on the next download.

## Deviations from the issues
- The start/due anchor lives in floret-kit (`ReminderLead`, floret-kit#19) rather than being packed into minute values. That PR is additive, so Calendula is unaffected.
- `error_reminder_without_due` was renamed to `error_reminder_without_date`, and the "(before start)/(before due)" strings were replaced by full sentences. The stale keys were deleted from the translation files so lint passes, and Weblate needs to offer the new strings to translators.
- The dead single-reminder API (`setAlarm`, `alarms()`, `editable()`, `reminderFor`) was removed.
- `AlarmLift` is back to its frozen 2→3 migration state.

## Release branch
`release/v1.2.0` is merged into this branch. Through it, this PR also brings main's commits the release branch was missing, including the reminder sync from #81 that this work builds on. I resolved conflicts in the data source interface, `TaskAlarmDao`, CHANGELOG, PRIVACY.md and the floret-kit pointer by keeping both sides.

Closes #72
Closes #78

Co-authored-by: Weblate <noreply-mt-weblate@weblate.org>
Co-authored-by: Sup99999 <alelmnour1o234@proton.me>
Co-authored-by: Robin Schanbacher <robin@schanbros.de>
Co-authored-by: Knee8811 <mk-weby@protonmail.com>
Co-authored-by: Sonja Kraus <klein.felix@pm.me>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/88
2026-10-21 18:40:28 +02:00
Jean-Luc Makiolaandmakiolaj 5004f5213c Align motion and settings with Calendula (#66) (#69)
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
Brings Agendula's screen transitions and settings in line with Calendula.

**Motion**
- Screens slide in on the M3 Expressive spring and leave through floret's `predictiveBackExit`, everywhere: main navigation and every settings sub-screen.
- Settings sub-screens get Calendula's back-swipe preview (shrink, edge shift, rounded corners). The central settings BackHandler is gone; each screen handles its own back.
- Main navigation keeps NavHost's own predictive back, which seeks the new exit over the previous screen.
- `enableOnBackInvokedCallback` is set, so Android 14/15 show the preview too.

**Settings**
- Reminders → Notifications, Task form → New task form, with Calendula's subtitles and labels inside Notifications.
- Export and import move out of Storage into a new Backup & restore section; Storage keeps only the task store.
- The language row uses floret-kit's `LanguagePickerRow` with the translate header.

**Deviations from the issue**
- The main-navigation swipe preview scales and fades but doesn't shift or round its corners like Calendula's. Matching that exactly would mean replacing navigation-compose with Calendula-style overlays. Settings match exactly.
- The AOSP/GrapheneOS Settings transition suggested in the issue isn't adopted; the family stays on its M3 Expressive motion.
- Renamed labels keep their string keys, so existing translations stay until Weblate re-reviews them rather than falling back to English.

Depends on jlmakiola/floret-kit#17 (the submodule points at it).

Closes #66

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/69
2026-10-19 17:14:31 +02:00
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
95 changed files with 8666 additions and 663 deletions
+43
View File
@@ -7,6 +7,49 @@ All notable changes to this project are documented here. The format follows
## [Unreleased]
### Added
- A reminder can count from the task's start as well as from its due date, and
tasks with only a start date get reminders too. Default reminders can mix
both, e.g. a day before the start and an hour before the due date; each
applies to tasks that have its date (#72).
- An all-day task's reminders can each have their own time, so one task can
remind you at 09:00 and again at 17:00 (#78).
- Other apps can read the tasks kept in Agendula's own database through an
OpenTasks-compatible provider, once you grant them permission. Apps that
already support OpenTasks or tasks.org need only a small change to support
Agendula too (#43).
- With a second permission, other apps can also add, edit, complete and delete
those tasks, including their reminders and tags. They can tick off a single
occurrence of a recurring task. Task lists stay read-only for them (#63).
### Changed
- A new task no longer takes a default reminder whose time has already
passed, so a start of "today" does not ring the moment the task is saved.
- All-day default reminders are whole days; one set in hours rounds to the
nearest day.
- Switching a task between timed and all-day converts its reminders right
away in the editor, so what you see is what gets saved.
- The editor shows the default reminders a new task, or a newly added date,
will get, before you save. Removing them leaves the task without a reminder
instead of falling back to the default.
- Reminders now sync. A reminder set on a task is uploaded as a standard alarm
(`VALARM`), so it also notifies in macOS and iOS Reminders, Thunderbird,
tasks.org and jtx Board, and alarms set in those apps now fire in Agendula
instead of the default reminder. Removing a task's alarm in another app
turns it off here too, rather than falling back to the default (#52).
- A new task with a date, or one that gets a new date, takes the default
reminder for that date as its own, so it travels with the task to the server.
Changing the default later no longer changes those tasks; tasks that rely on
the default today are left as they are.
- On upgrade, reminders on open, synced tasks that so far stayed on this
device are uploaded once.
- Alarms imported from an `.ics` file become reminders on the task.
- Screens now open and close the way they do in Calendula: they slide in, and
a back swipe previews the screen behind before closing (#66).
- Settings now use Calendula's names and layout. Reminders is now
Notifications, Task form is now New task form, and export and import moved
out of Storage into a new Backup & restore section (#66).
## [1.1.0] - 2026-10-10
### Added
+36 -7
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>
@@ -50,7 +54,8 @@ many small *florets* — so the two apps are florets of one bloom.)
this and the following ones, or the whole series.
- **Reminders** that fire at the exact time: several per task, separate defaults
for timed and all-day tasks (globally and per list), Done and Snooze right on
the notification, and they survive reboots.
the notification, and they survive reboots. They sync as standard `VALARM`s,
so they also ring in other CalDAV clients, and theirs ring here.
- **iCalendar import and export** — open an `.ics` or a zip of them, or write any
list out as standard `.ics` files.
- A **home-screen widget**, launcher shortcuts, a Quick Settings tile, and
@@ -97,6 +102,10 @@ Agendula's own store is an ordinary app database, so it **coexists with
OpenTasks rather than replacing it**. If you already sync through a provider,
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
@@ -107,10 +116,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):
@@ -135,12 +154,21 @@ app.
Betas of upcoming versions are published there too, as pre-releases; to test
them, switch on *Include prereleases* for Agendula in Obtainium.
### Google Play
Agendula is on
[Google Play](https://play.google.com/store/apps/details?id=de.jeanlucmakiola.agendula).
Play builds are signed with Google's key rather than mine, so switching between
Play and any other channel means uninstalling first. Tasks kept only in
Agendula's own database go with it, so sync them to a CalDAV account first.
### Offline version
Each Codeberg release also carries `agendula-offline_v<version>.apk`: Agendula
without CalDAV sync and **without the network permission**, so it cannot
connect to anything. It installs alongside the regular app rather than replacing
it; move tasks between the two with Settings → Storage → Export / Import. In
it; move tasks between the two with Settings → Backup & restore → Export / Import. In
Obtainium, pick it with *Filter APKs by regular expression* set to
`^agendula-offline_v`.
@@ -158,8 +186,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 = 1010099
versionName = "1.1.0"
versionCode = 1020001
versionName = "1.2.0-beta.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -0,0 +1,548 @@
{
"formatVersion": 1,
"database": {
"version": 3,
"identityHash": "725292b38bce25ce9032c4650573ae85",
"entities": [
{
"tableName": "accounts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `display_name` TEXT NOT NULL, `principal_url` TEXT, `home_set_url` TEXT, `username` TEXT, `last_sync_at` INTEGER, `last_sync_error` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "display_name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "principalUrl",
"columnName": "principal_url",
"affinity": "TEXT"
},
{
"fieldPath": "homeSetUrl",
"columnName": "home_set_url",
"affinity": "TEXT"
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT"
},
{
"fieldPath": "lastSyncAt",
"columnName": "last_sync_at",
"affinity": "INTEGER"
},
{
"fieldPath": "lastSyncError",
"columnName": "last_sync_error",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "task_lists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `account_id` INTEGER, `is_visible` INTEGER NOT NULL DEFAULT 1, `is_synced` INTEGER NOT NULL DEFAULT 1, `owner` TEXT, `is_read_only` INTEGER NOT NULL DEFAULT 0, `sort_order` INTEGER NOT NULL DEFAULT 0, `href` TEXT, `ctag` TEXT, `sync_token` TEXT, `is_dirty` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`account_id`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "account_id",
"affinity": "INTEGER"
},
{
"fieldPath": "isVisible",
"columnName": "is_visible",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "1"
},
{
"fieldPath": "isSynced",
"columnName": "is_synced",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "1"
},
{
"fieldPath": "owner",
"columnName": "owner",
"affinity": "TEXT"
},
{
"fieldPath": "isReadOnly",
"columnName": "is_read_only",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "sortOrder",
"columnName": "sort_order",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "href",
"columnName": "href",
"affinity": "TEXT"
},
{
"fieldPath": "ctag",
"columnName": "ctag",
"affinity": "TEXT"
},
{
"fieldPath": "syncToken",
"columnName": "sync_token",
"affinity": "TEXT"
},
{
"fieldPath": "isDirty",
"columnName": "is_dirty",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_task_lists_account_id",
"unique": false,
"columnNames": [
"account_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_task_lists_account_id` ON `${TABLE_NAME}` (`account_id`)"
}
],
"foreignKeys": [
{
"table": "accounts",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"account_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "tasks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `list_id` INTEGER NOT NULL, `uid` TEXT NOT NULL, `href` TEXT, `etag` TEXT, `title` TEXT, `description` TEXT, `location` TEXT, `url` TEXT, `color` INTEGER, `categories` TEXT NOT NULL DEFAULT '', `status` INTEGER NOT NULL DEFAULT 0, `percent_complete` INTEGER, `completed_at` INTEGER, `priority` INTEGER NOT NULL DEFAULT 0, `classification` INTEGER, `dtstart` INTEGER, `due` INTEGER, `duration` TEXT, `is_all_day` INTEGER NOT NULL DEFAULT 0, `timezone` TEXT, `rrule` TEXT, `rdate` TEXT, `exdate` TEXT, `recurrence_id` INTEGER, `master_id` INTEGER, `parent_id` INTEGER, `sort_order` INTEGER NOT NULL DEFAULT 0, `created_at` INTEGER, `last_modified` INTEGER, `sequence` INTEGER NOT NULL DEFAULT 0, `is_dirty` INTEGER NOT NULL DEFAULT 0, `is_deleted` INTEGER NOT NULL DEFAULT 0, `unknown_properties` TEXT, `reminders_off` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`list_id`) REFERENCES `task_lists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`master_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parent_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "listId",
"columnName": "list_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "href",
"columnName": "href",
"affinity": "TEXT"
},
{
"fieldPath": "etag",
"columnName": "etag",
"affinity": "TEXT"
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT"
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT"
},
{
"fieldPath": "location",
"columnName": "location",
"affinity": "TEXT"
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT"
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "INTEGER"
},
{
"fieldPath": "categories",
"columnName": "categories",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "percentComplete",
"columnName": "percent_complete",
"affinity": "INTEGER"
},
{
"fieldPath": "completedAt",
"columnName": "completed_at",
"affinity": "INTEGER"
},
{
"fieldPath": "priority",
"columnName": "priority",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "classification",
"columnName": "classification",
"affinity": "INTEGER"
},
{
"fieldPath": "dtstart",
"columnName": "dtstart",
"affinity": "INTEGER"
},
{
"fieldPath": "due",
"columnName": "due",
"affinity": "INTEGER"
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "TEXT"
},
{
"fieldPath": "isAllDay",
"columnName": "is_all_day",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "timezone",
"columnName": "timezone",
"affinity": "TEXT"
},
{
"fieldPath": "rrule",
"columnName": "rrule",
"affinity": "TEXT"
},
{
"fieldPath": "rdate",
"columnName": "rdate",
"affinity": "TEXT"
},
{
"fieldPath": "exdate",
"columnName": "exdate",
"affinity": "TEXT"
},
{
"fieldPath": "recurrenceId",
"columnName": "recurrence_id",
"affinity": "INTEGER"
},
{
"fieldPath": "masterId",
"columnName": "master_id",
"affinity": "INTEGER"
},
{
"fieldPath": "parentId",
"columnName": "parent_id",
"affinity": "INTEGER"
},
{
"fieldPath": "sortOrder",
"columnName": "sort_order",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER"
},
{
"fieldPath": "lastModified",
"columnName": "last_modified",
"affinity": "INTEGER"
},
{
"fieldPath": "sequence",
"columnName": "sequence",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isDirty",
"columnName": "is_dirty",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isDeleted",
"columnName": "is_deleted",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "unknownProperties",
"columnName": "unknown_properties",
"affinity": "TEXT"
},
{
"fieldPath": "remindersOff",
"columnName": "reminders_off",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_tasks_list_id_is_deleted",
"unique": false,
"columnNames": [
"list_id",
"is_deleted"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_list_id_is_deleted` ON `${TABLE_NAME}` (`list_id`, `is_deleted`)"
},
{
"name": "index_tasks_parent_id",
"unique": false,
"columnNames": [
"parent_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_parent_id` ON `${TABLE_NAME}` (`parent_id`)"
},
{
"name": "index_tasks_master_id_recurrence_id",
"unique": false,
"columnNames": [
"master_id",
"recurrence_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_master_id_recurrence_id` ON `${TABLE_NAME}` (`master_id`, `recurrence_id`)"
},
{
"name": "index_tasks_is_dirty",
"unique": false,
"columnNames": [
"is_dirty"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_is_dirty` ON `${TABLE_NAME}` (`is_dirty`)"
},
{
"name": "index_tasks_list_id_uid_recurrence_id",
"unique": true,
"columnNames": [
"list_id",
"uid",
"recurrence_id"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tasks_list_id_uid_recurrence_id` ON `${TABLE_NAME}` (`list_id`, `uid`, `recurrence_id`)"
}
],
"foreignKeys": [
{
"table": "task_lists",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"list_id"
],
"referencedColumns": [
"id"
]
},
{
"table": "tasks",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"master_id"
],
"referencedColumns": [
"id"
]
},
{
"table": "tasks",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"parent_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "task_alarms",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `task_id` INTEGER NOT NULL, `minutes_before` INTEGER NOT NULL, `reference` TEXT NOT NULL DEFAULT 'DUE', `message` TEXT, `uid` TEXT NOT NULL DEFAULT '', `raw` TEXT, FOREIGN KEY(`task_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "taskId",
"columnName": "task_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "minutesBefore",
"columnName": "minutes_before",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "reference",
"columnName": "reference",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'DUE'"
},
{
"fieldPath": "message",
"columnName": "message",
"affinity": "TEXT"
},
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "raw",
"columnName": "raw",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_task_alarms_task_id",
"unique": false,
"columnNames": [
"task_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_task_alarms_task_id` ON `${TABLE_NAME}` (`task_id`)"
}
],
"foreignKeys": [
{
"table": "tasks",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"task_id"
],
"referencedColumns": [
"id"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '725292b38bce25ce9032c4650573ae85')"
]
}
}
@@ -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) }
}
}
}
@@ -5,7 +5,7 @@ import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskStatus
import org.junit.After
@@ -366,41 +366,23 @@ class RoomTasksDataSourceTest {
@Test
fun alarmsRoundTripAndReplaceRatherThanAccumulate() {
val id = source.insertTask(form(due = now + 1.days))
val start = TaskReminder(minutesBefore = 10, fromStart = true)
// The whole reminder, not just the minute count: collapsing it to a bare
// Int is what fired an imported START-referenced alarm off DUE, and an
// alarm this seam sets from the UI is always due-referenced.
source.setAlarm(id, 30)
assertThat(source.alarms()[id]).isEqualTo(TaskReminder(minutesBefore = 30))
source.setAlarm(id, 60)
assertThat(db.alarms().forTask(id)).hasSize(1)
assertThat(source.alarms()[id]).isEqualTo(TaskReminder(minutesBefore = 60))
source.setAlarm(id, null)
assertThat(source.alarms()).doesNotContainKey(id)
}
@Test
fun settingTheEditableReminderLeavesTheOthersAlone() {
val id = source.insertTask(form(due = now + 1.days))
val start = TaskReminder(minutesBefore = 10, fromStart = true)
source.setReminders(id, listOf(start, TaskReminder(30), TaskReminder(120)))
source.setAlarm(id, 45)
assertThat(source.reminders()[id]).containsExactly(start, TaskReminder(30), TaskReminder(45)).inOrder()
assertThat(source.alarms()[id]).isEqualTo(TaskReminder(45))
source.setAlarm(id, null)
// Int is what fired an imported START-referenced alarm off DUE.
source.setReminders(id, listOf(start, TaskReminder(30)))
assertThat(source.reminders()[id]).containsExactly(start, TaskReminder(30)).inOrder()
assertThat(source.alarms()[id]).isEqualTo(TaskReminder(30))
source.setReminders(id, listOf(TaskReminder(60)))
assertThat(db.alarms().forTask(id)).hasSize(1)
assertThat(source.reminders()[id]).containsExactly(TaskReminder(60))
}
@Test
fun forkingAnOccurrenceCarriesTheReminderOntoIt() {
val id = source.insertTask(form(due = now + 1.days))
makeRecurring(id, now)
source.setAlarm(id, 30)
source.setReminders(id, listOf(TaskReminder(30)))
val target = source.tasks(TaskQuery(listId = listId))
.filter { it.taskId == id }
.first { it.distanceFromCurrent == 1 }
@@ -411,6 +393,134 @@ class RoomTasksDataSourceTest {
assertThat(db.alarms().forTask(override.id).single().minutesBefore).isEqualTo(30)
}
@Test
fun aSaveKeepsTheIdentityOfAReminderItDidNotChange() {
val id = source.insertTask(form(due = now + 1.days), listOf(TaskReminder(15)))
val original = db.alarms().forTask(id).single()
db.alarms().update(original.copy(raw = "BEGIN:VALARM\r\nX-VENDOR:keep\r\nEND:VALARM\r\n"))
source.updateTask(id, form(title = "renamed", due = now + 2.days), reminders = listOf(TaskReminder(15), TaskReminder(60)))
val rows = db.alarms().forTask(id)
val kept = rows.single { it.minutesBefore == 15 }
// Same row, same identity on the server, same original text.
assertThat(kept.id).isEqualTo(original.id)
assertThat(kept.uid).isEqualTo(original.uid)
assertThat(kept.raw).contains("X-VENDOR:keep")
assertThat(rows.single { it.minutesBefore == 60 }.uid).isNotEqualTo(original.uid)
}
@Test
fun aSaveWritesBeforeStartAndBeforeDueRemindersAlike() {
val id = source.insertTask(form(due = now + 1.days))
source.setReminders(id, listOf(TaskReminder(10, fromStart = true), TaskReminder(30)))
source.updateTask(id, form(due = now + 1.days), reminders = listOf(TaskReminder(5, fromStart = true), TaskReminder(45)))
assertThat(source.reminders()[id]).containsExactly(TaskReminder(5, fromStart = true), TaskReminder(45))
}
@Test
fun aReminderForOneOccurrenceLeavesTheSeriesUntouched() {
val work = syncedList()
val id = source.insertTask(form(due = now + 1.days).copy(listId = work), listOf(TaskReminder(30)))
makeRecurring(id, now)
db.tasks().update(db.tasks().entity(id)!!.copy(isDirty = false))
val target = source.tasks(TaskQuery(listId = work))
.filter { it.taskId == id }
.first { it.distanceFromCurrent == 1 }
source.updateInstance(id, target.occurrenceStart!!, form(title = "moved").copy(listId = work), listOf(TaskReminder(60)))
// ⚠️ The master is never written, not even briefly: a sync landing in
// between would upload the occurrence's reminder for the whole series.
assertThat(db.alarms().forTask(id).map { it.minutesBefore }).containsExactly(30)
assertThat(db.tasks().entity(id)!!.isDirty).isFalse()
val override = db.tasks().override(id, target.occurrenceStart)!!
assertThat(db.alarms().forTask(override.id).map { it.minutesBefore }).containsExactly(60)
}
@Test
fun splittingASeriesGivesTheNewSeriesItsOwnReminderIdentities() {
val id = source.insertTask(form(due = now + 1.days), listOf(TaskReminder(30)))
makeRecurring(id, now - 14.days)
val master = db.alarms().forTask(id).single()
val target = source.tasks(TaskQuery(listId = listId))
.filter { it.taskId == id }
.first { it.distanceFromCurrent == 1 }
val created = source.splitSeries(id, target.occurrenceStart!!, form(title = "later"), listOf(TaskReminder(30)))
assertThat(created).isNotEqualTo(id)
val copy = db.alarms().forTask(created).single()
assertThat(copy.minutesBefore).isEqualTo(30)
// A different UID on the server, so the alarm must not share one either.
assertThat(copy.uid).isNotEqualTo(master.uid)
assertThat(db.alarms().forTask(id).single().uid).isEqualTo(master.uid)
}
@Test
fun changingOnlyTheRemindersOwesTheServerAPut() {
val work = syncedList()
val id = source.insertTask(form(due = now + 1.days).copy(listId = work))
db.tasks().update(db.tasks().entity(id)!!.copy(isDirty = false))
source.setReminders(id, listOf(TaskReminder(15)))
assertThat(db.tasks().entity(id)!!.isDirty).isTrue()
db.tasks().update(db.tasks().entity(id)!!.copy(isDirty = false))
source.setReminders(id, listOf(TaskReminder(15)))
// Nothing changed, so nothing is owed.
assertThat(db.tasks().entity(id)!!.isDirty).isFalse()
}
@Test
fun aSeriesReminderEditReachesTheOccurrencesThatStillHadTheOldOne() {
val id = source.insertTask(form(due = now + 1.days), listOf(TaskReminder(15)))
makeRecurring(id, now)
val occurrences = source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id }
val completed = occurrences.first { it.distanceFromCurrent == 1 }.occurrenceStart!!
val own = occurrences.first { it.distanceFromCurrent == 2 }.occurrenceStart!!
source.setCompletedInstance(id, completed, true)
source.updateInstance(id, own, form(title = "own"), listOf(TaskReminder(5)))
// Same times and rule, so the series is not reshaped — that drops its overrides.
val first = occurrences.first()
val unchanged = form().copy(start = first.start, due = first.due, rrule = "FREQ=WEEKLY")
source.updateSeries(id, first.occurrenceStart!!, unchanged, listOf(TaskReminder(60)))
fun leads(anchor: Instant) = db.alarms().forTask(db.tasks().override(id, anchor)!!.id).map { it.minutesBefore }
assertThat(db.alarms().forTask(id).map { it.minutesBefore }).containsExactly(60)
// A snapshot of the series' reminder follows it…
assertThat(leads(completed)).containsExactly(60)
// …one set for that occurrence alone does not.
assertThat(leads(own)).containsExactly(5)
}
@Test
fun aTaskWhoseAlarmsWereTurnedOffElsewhereReportsNone() {
val id = source.insertTask(form(due = now + 1.days))
db.alarms().setRemindersOff(id, true)
assertThat(source.reminders()[id]).isEmpty()
assertThat(source.reminders()).containsKey(id)
// A reminder of its own again: the flag goes.
source.setReminders(id, listOf(TaskReminder(15)))
assertThat(db.tasks().entity(id)!!.remindersOff).isFalse()
}
@Test
fun settingRemindersOwesTheServerAPut() {
val work = syncedList()
val id = source.insertTask(form(due = now + 1.days).copy(listId = work))
db.tasks().update(db.tasks().entity(id)!!.copy(isDirty = false))
source.setReminders(id, listOf(TaskReminder(30)))
assertThat(db.tasks().entity(id)!!.isDirty).isTrue()
}
@Test
fun deletingATaskInALocalListRemovesItOutright() {
val id = source.insertTask(form())
@@ -439,7 +549,7 @@ class RoomTasksDataSourceTest {
fun aForkedOccurrenceCarriesTheSeriesReminder() {
val id = source.insertTask(form())
makeRecurring(id, now)
source.setAlarm(id, minutesBeforeDue = 30)
source.setReminders(id, listOf(TaskReminder(30)))
val target = source.tasks(TaskQuery(listId = listId))
.filter { it.taskId == id }
.first { it.distanceFromCurrent == 1 }
@@ -447,7 +557,7 @@ class RoomTasksDataSourceTest {
source.updateInstance(id, target.occurrenceStart!!, form(title = "moved"))
// The fork copies the master's properties, which is what carries the
// reminder across — the repository is what puts the series' own back.
// reminder across.
val override = db.tasks().override(id, target.occurrenceStart)!!
assertThat(db.alarms().forTask(override.id).single().minutesBefore).isEqualTo(30)
}
@@ -539,4 +649,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()
}
}
@@ -75,6 +75,78 @@ class TasksDatabaseMigrationTest {
}
}
@Test
fun liftsClaimableAlarmsAndPushesRemindersThatNeverLeftTheDevice() {
val future = System.currentTimeMillis() + 7L * 24 * 60 * 60 * 1000
val past = System.currentTimeMillis() - 7L * 24 * 60 * 60 * 1000
val claimable = "X-FOO:bar\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nX-WR-ALARMUID:srv\r\n" +
"TRIGGER;RELATED=END:-PT15M\r\nEND:VALARM\r\n"
val appleDefault = "BEGIN:VALARM\r\nACTION:DISPLAY\r\nX-APPLE-DEFAULT-ALARM:TRUE\r\n" +
"TRIGGER;RELATED=END:-PT30M\r\nEND:VALARM\r\n"
helper.createDatabase(TEST_DB, 2).use { db ->
db.execSQL("INSERT INTO accounts (id, display_name) VALUES (1, 'me')")
db.execSQL("INSERT INTO task_lists (id, name, color, account_id) VALUES (1, 'Synced', 0, 1)")
db.execSQL("INSERT INTO task_lists (id, name, color) VALUES (2, 'Local', 0)")
db.execSQL("INSERT INTO task_lists (id, name, color, account_id, is_read_only) VALUES (3, 'Shared', 0, 1, 1)")
fun task(id: Int, list: Int, due: Long, residue: String? = null) = db.execSQL(
"INSERT INTO tasks (id, list_id, uid, title, due, unknown_properties) VALUES (?, ?, ?, ?, ?, ?)",
arrayOf<Any?>(id, list, "uid-$id", "Task $id", due, residue),
)
fun alarm(task: Int, minutes: Int) =
db.execSQL("INSERT INTO task_alarms (task_id, minutes_before) VALUES (?, ?)", arrayOf<Any?>(task, minutes))
// A server alarm, and the same lead copied locally by a fork.
task(1, 1, future, claimable)
alarm(1, 15)
// Set in Agendula, never uploaded — still to fire, already past, device-only.
task(2, 1, future)
alarm(2, 30)
task(3, 1, past)
alarm(3, 30)
task(4, 2, future)
alarm(4, 30)
// Somebody else's default alarm: not ours to claim.
task(5, 1, future, appleDefault)
// A read-only share: dirty would freeze it, never sent and never refreshed.
task(6, 3, future)
alarm(6, 30)
}
helper.runMigrationsAndValidate(TEST_DB, 3, true, TasksDatabase.MIGRATION_2_3).use { db ->
db.query("SELECT task_id, minutes_before, uid, raw FROM task_alarms ORDER BY task_id").use { c ->
val rows = buildList { while (c.moveToNext()) add(listOf(c.getLong(0), c.getInt(1), c.getString(2), c.getString(3))) }
// One for task 1: the lifted copy won and the local duplicate went.
assertThat(rows.map { it[0] }).containsExactly(1L, 2L, 3L, 4L, 6L).inOrder()
assertThat(rows[0][2]).isEqualTo("srv")
assertThat(rows[0][3] as String).contains("TRIGGER;RELATED=END:-PT15M")
// Every pre-existing row got an identity of its own, shaped as a UUID.
assertThat(rows.drop(1).map { it[2] as String }.toSet()).hasSize(4)
rows.drop(1).forEach {
assertThat(it[2] as String).matches("[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}")
}
assertThat(rows.drop(1).all { (it[2] as String).isNotEmpty() && it[3] == null }).isTrue()
}
db.query("SELECT id, unknown_properties, is_dirty, last_modified, reminders_off FROM tasks ORDER BY id").use { c ->
c.moveToNext()
assertThat(c.getString(1)).isEqualTo("X-FOO:bar\r\n")
assertThat(c.getInt(2)).isEqualTo(0)
assertThat(c.getInt(4)).isEqualTo(0)
c.moveToNext()
assertThat(c.getInt(2)).isEqualTo(1)
// The body changes, so its modification stamp does too.
assertThat(c.isNull(3)).isFalse()
c.moveToNext()
assertThat(c.getInt(2)).isEqualTo(0)
c.moveToNext()
assertThat(c.getInt(2)).isEqualTo(0)
c.moveToNext()
assertThat(c.getString(1)).contains("X-APPLE-DEFAULT-ALARM:TRUE")
assertThat(c.getInt(2)).isEqualTo(0)
c.moveToNext()
assertThat(c.getInt(2)).isEqualTo(0)
}
}
}
private companion object {
const val TEST_DB = "migration-test.db"
}
@@ -11,7 +11,7 @@ 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.TaskQuery
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
@@ -294,23 +294,25 @@ private class FakeExternalStore : TasksDataSource {
return tasks[listId].orEmpty()
}
override fun alarms(): Map<Long, TaskReminder> = alarms
override fun reminders(): Map<Long, List<TaskReminder>> = alarms.mapValues { listOf(it.value) }
override fun tasks(query: TaskQuery): List<Task> = unused()
override fun task(taskId: Long): Task? = unused()
override fun subtasks(parentTaskId: Long): List<Task> = unused()
override fun insertTask(form: TaskForm): Long = unused()
override fun updateTask(taskId: Long, form: TaskForm) = unused()
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) = unused()
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) = unused()
override fun insertTask(form: TaskForm, reminders: List<TaskReminder>?): Long = unused()
override fun updateTask(taskId: Long, form: TaskForm, reminders: List<TaskReminder>?) = unused()
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) =
unused()
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) = unused()
override fun setCompleted(taskId: Long, completed: Boolean) = unused()
override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) = unused()
override fun deleteTask(taskId: Long) = unused()
override fun setCancelled(taskId: Long, cancelled: Boolean) = unused()
override fun setCancelledInstance(taskId: Long, occurrenceStart: Instant, cancelled: Boolean) = unused()
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) = unused()
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm): Long = unused()
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) =
unused()
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?): Long =
unused()
override fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant) = unused()
override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) = unused()
override fun createLocalList(name: String, color: Int): Long = unused()
@@ -1,8 +1,10 @@
package de.jeanlucmakiola.agendula.data.sync
import de.jeanlucmakiola.agendula.data.tasks.ical.AlarmCodec
import de.jeanlucmakiola.agendula.data.tasks.ical.CalendarResource
import de.jeanlucmakiola.agendula.data.tasks.ical.ResourceValidator
import de.jeanlucmakiola.agendula.data.tasks.ical.VTodoMapper
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
import at.bitfire.dav4jvm.exception.UnauthorizedException
@@ -422,8 +424,9 @@ class CollectionSyncer(
/** Null when the resource was rejected before it left the device. */
private fun serialize(local: LocalResource): String? {
val alarms = store.alarmsFor(local.live.map { it.id })
val todos = local.live.map { row ->
VTodoMapper.write(row, parentUidOf(row), now())
VTodoMapper.write(row, parentUidOf(row), now(), alarms[row.id].orEmpty())
}
val calendar = CalendarResource.build(todos)
@@ -778,14 +781,17 @@ class CollectionSyncer(
val losing = existing.values.firstOrNull { it.isDirty }
val master = mapped.firstOrNull { it.entity.recurrenceId == null } ?: mapped.first()
val masterRow = upsert(master.entity, existing[master.entity.recurrenceId], null, href, eTag)
val masterRow = upsert(master, existing[master.entity.recurrenceId], null, href, eTag)
val masterId = masterRow.id
parents[uid] = master.parentUid
val written = mutableListOf(masterRow)
mapped.filter { it !== master }.forEach { override ->
// ⚠️ An override's alarms are its own: RFC 5545 has an override
// replace the component, so one the server sends without a
// VALARM has none, whatever its master carries.
written += upsert(
override.entity, existing[override.entity.recurrenceId], masterId, href, eTag,
override, existing[override.entity.recurrenceId], masterId, href, eTag,
)
}
val kept = written.map { it.recurrenceId }.toSet()
@@ -832,12 +838,13 @@ class CollectionSyncer(
/** @return the row as it now stands, so the caller can index it. */
private fun upsert(
incoming: TaskEntity,
mapped: VTodoMapper.Mapped,
existing: TaskEntity?,
masterId: Long?,
href: String,
eTag: String?,
): TaskEntity {
val incoming = mapped.entity
// Local-only columns the server has no opinion about. Taking the
// mapper's defaults here would silently reset the user's ordering and
// per-task colour on every download.
@@ -854,12 +861,42 @@ class CollectionSyncer(
// set uploads what was just downloaded.
isDirty = false,
isDeleted = false,
// The store's to change, from what the alarms do.
remindersOff = existing?.remindersOff ?: false,
)
return if (existing == null) {
row.copy(id = store.insert(row))
} else {
store.update(row)
row
// The server's alarms replace ours, as every other field does: a
// dirty row is never downloaded except to discard its edit.
val alarms = existing?.let { keepLocalPrecision(mapped.alarms, it, row) } ?: mapped.alarms
return row.copy(id = store.upsertWithAlarms(row, alarms))
}
/**
* [incoming] with each alarm we uploaded ourselves put back to the lead
* we hold for it.
*
* ⚠️ The wire is coarser than the row: a lead past the end of its
* anchor is clamped, and a before-start reminder on a task without a
* start goes out before-due. Taking the round trip at its word would
* quietly turn a before-start reminder into a before-due one the first
* time the task is downloaded again.
* An alarm that comes back as exactly what we would have sent has not
* been changed, so the row's own lead stands.
*/
private fun keepLocalPrecision(
incoming: List<TaskAlarmEntity>,
existing: TaskEntity,
task: TaskEntity,
): List<TaskAlarmEntity> {
val local = store.alarmsFor(listOf(existing.id))[existing.id].orEmpty()
if (local.isEmpty()) return incoming
return incoming.map { alarm ->
val mine = local.firstOrNull { it.uid == alarm.uid } ?: return@map alarm
val sent = AlarmCodec.wireLead(mine, task)
if (sent == AlarmCodec.Lead(alarm.minutesBefore, alarm.reference)) {
alarm.copy(minutesBefore = mine.minutesBefore, reference = mine.reference)
} else {
alarm
}
}
}
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.agendula.data.sync
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import javax.inject.Inject
@@ -11,17 +12,33 @@ import javax.inject.Inject
* discovery: the reconciliation above this interface is where local edits are
* discarded, tombstones swept and conflicts resolved, and every one of those is
* a decision that should be provable without a device. Room's test double is
* Robolectric plus an in-memory database; this is eight methods.
* Robolectric plus an in-memory database; this is a dozen methods.
*/
interface SyncStore {
/** Every row in a list, **tombstones included**. */
fun rowsIn(listId: Long): List<TaskEntity>
fun insert(row: TaskEntity): Long
fun update(row: TaskEntity)
/** The reminders of [taskIds] — one resource's rows — by task id, for the body that uploads them. */
fun alarmsFor(taskIds: List<Long>): Map<Long, List<TaskAlarmEntity>>
/**
* Writes a downloaded row — inserted when its id is 0 — and makes its
* reminders exactly [alarms], in one transaction.
*
* ⚠️ One transaction, not an [update] and a second call. The row lands clean
* with the server's ETag, so if the process died between the two, nothing
* would ever download it again and the reminders would stay stale for good.
*
* A row that is gone by the time it is written — deleted on the device
* while the sync ran — stays gone, reminders and all.
*
* @return the row's id.
*/
fun upsertWithAlarms(row: TaskEntity, alarms: List<TaskAlarmEntity>): Long
fun deleteAll(taskIds: List<Long>)
/**
@@ -56,12 +73,26 @@ class RoomSyncStore @Inject constructor(
override fun rowsIn(listId: Long) = database.tasks().allIn(listId)
override fun insert(row: TaskEntity) = database.tasks().insert(row)
override fun update(row: TaskEntity) {
database.tasks().update(row)
}
override fun alarmsFor(taskIds: List<Long>) =
if (taskIds.isEmpty()) emptyMap() else database.alarms().forTasks(taskIds).groupBy { it.taskId }
override fun upsertWithAlarms(row: TaskEntity, alarms: List<TaskAlarmEntity>): Long =
database.runInTransaction<Long> {
if (row.id == 0L) {
database.tasks().insert(row).also { database.alarms().replaceFromServer(it, alarms) }
} else {
// ⚠️ Zero rows means the row was deleted under us. Its alarms
// would then name a task that does not exist, and the foreign
// key would fail the whole transaction.
if (database.tasks().update(row) > 0) database.alarms().replaceFromServer(row.id, alarms)
row.id
}
}
override fun deleteAll(taskIds: List<Long>) {
if (taskIds.isNotEmpty()) database.tasks().deleteAll(taskIds)
}
@@ -70,6 +70,7 @@ internal fun AccountDetailScreen(
onSignInAgain: (AccountEntity) -> Unit,
onOpenStorage: () -> Unit,
viewModel: AccountsViewModel,
visible: Boolean,
) {
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
val usable by viewModel.accountsUsable.collectAsStateWithLifecycle()
@@ -90,7 +91,7 @@ internal fun AccountDetailScreen(
// shown on the accounts list rather than here.
var confirming by remember { mutableStateOf(false) }
CollapsingScaffold(title = identity.title, onBack = onBack) {
CollapsingScaffold(title = identity.title, onBack = onBack, predictiveBack = true, visible = visible) {
AccountHero(identity)
Spacer(Modifier.height(24.dp))
@@ -48,6 +48,7 @@ internal fun AccountsScreen(
onOpenStorage: () -> Unit,
onBack: () -> Unit,
viewModel: AccountsViewModel,
visible: Boolean,
) {
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
val usable by viewModel.accountsUsable.collectAsStateWithLifecycle()
@@ -60,6 +61,8 @@ internal fun AccountsScreen(
CollapsingScaffold(
title = stringResource(R.string.settings_section_accounts),
onBack = onBack,
predictiveBack = true,
visible = visible,
) {
val loaded = accounts ?: return@CollapsingScaffold
@@ -31,8 +31,8 @@ internal fun AccountSections(
// Which account the detail screen is showing; the section alone cannot say.
var openAccount by rememberSaveable(initialAccountId) { mutableStateOf(initialAccountId) }
// Accounts stays composed under Add account, for the same reason Storage
// stays composed under Export: the deeper screen slides over it.
// Accounts stays composed under Add account, for the same reason Backup
// stays composed under export and import: the deeper screen slides over it.
val accountsOpen = section == SettingsSection.Accounts ||
section?.parent == SettingsSection.Accounts
SlideInSection(visible = accountsOpen) {
@@ -50,9 +50,11 @@ internal fun AccountSections(
onOpenStorage = { onSection(SettingsSection.Storage) },
onBack = { onSection(null) },
viewModel = accountsViewModel,
visible = accountsOpen,
)
}
SlideInSection(visible = section == SettingsSection.Account) {
val accountOpen = section == SettingsSection.Account
SlideInSection(visible = accountOpen) {
openAccount?.let { id ->
AccountDetailScreen(
accountId = id,
@@ -61,6 +63,7 @@ internal fun AccountSections(
onSignInAgain = signInAgain,
onOpenStorage = { onSection(SettingsSection.Storage) },
viewModel = accountsViewModel,
visible = accountOpen,
)
}
}
+26 -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
@@ -37,6 +50,7 @@
<application
android:name=".AgendulaApp"
android:allowBackup="true"
android:enableOnBackInvokedCallback="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
@@ -233,6 +247,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
}
}
@@ -89,7 +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)
.addMigrations(TasksDatabase.MIGRATION_1_2, TasksDatabase.MIGRATION_2_3)
.build()
/**
@@ -14,7 +14,11 @@ import de.jeanlucmakiola.agendula.domain.SavedFilter
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.domain.asTaskReminder
import de.jeanlucmakiola.agendula.domain.asReminderLead
import de.jeanlucmakiola.agendula.domain.TaskSortOrder
import de.jeanlucmakiola.floret.reminders.ReminderLead
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec
import de.jeanlucmakiola.floret.reminders.applyReminderOverride
@@ -57,10 +61,10 @@ data class Settings(
val dynamicColor: Boolean = true,
/** The list a new task defaults to; `null` = first available. */
val defaultListId: Long? = null,
/** Default reminders, as minutes before due (0 = at due time); empty = none. */
val defaultReminderMinutes: List<Int> = listOf(0),
/** Default reminders for all-day tasks, as days-scale minutes before [allDayReminderMinuteOfDay]. */
val defaultAllDayReminderMinutes: List<Int> = listOf(0),
/** Default reminders (0 before due = at due time); empty = none. */
val defaultReminders: List<ReminderLead> = listOf(ReminderLead(0)),
/** Default reminders for all-day tasks, in whole days, rung at [allDayReminderMinuteOfDay]. */
val defaultAllDayReminders: List<ReminderLead> = listOf(ReminderLead(0)),
/** Local time (minutes from midnight) an all-day task's reminder counts back from. */
val allDayReminderMinuteOfDay: Int = DEFAULT_ALL_DAY_REMINDER_MINUTE,
/** Master switch for due reminders; off clears every scheduled alarm. */
@@ -74,13 +78,13 @@ data class Settings(
*/
val bottomAddBar: Boolean = false,
/**
* Per-list overrides of [defaultReminderMinutes]: a list present in the map
* Per-list overrides of [defaultReminders]: a list present in the map
* overrides the global default (an empty list = no reminder); absent =
* inherit.
*/
val perListReminderOverride: Map<Long, List<Int>> = emptyMap(),
/** Per-list overrides of [defaultAllDayReminderMinutes], same shape as [perListReminderOverride]. */
val perListAllDayReminderOverride: Map<Long, List<Int>> = emptyMap(),
val perListReminderOverride: Map<Long, List<ReminderLead>> = emptyMap(),
/** Per-list overrides of [defaultAllDayReminders], same shape as [perListReminderOverride]. */
val perListAllDayReminderOverride: Map<Long, List<ReminderLead>> = emptyMap(),
/** Optional edit-form fields shown by default; the rest sit behind "More fields". */
val defaultEditFields: Set<TaskFormField> = emptySet(),
val sortOrder: TaskSortOrder = TaskSortOrder.DUE,
@@ -109,12 +113,16 @@ data class Settings(
}
/** The lead times for a task in [listId]: its override if set, else the global default. */
fun reminderLeadsFor(listId: Long): List<Int> =
perListReminderOverride.reminderLeadsFor(listId, defaultReminderMinutes)
fun reminderLeadsFor(listId: Long): List<ReminderLead> =
perListReminderOverride.reminderLeadsFor(listId, defaultReminders)
/** The lead times for an all-day task in [listId]. */
fun allDayReminderLeadsFor(listId: Long): List<Int> =
perListAllDayReminderOverride.reminderLeadsFor(listId, defaultAllDayReminderMinutes)
fun allDayReminderLeadsFor(listId: Long): List<ReminderLead> =
perListAllDayReminderOverride.reminderLeadsFor(listId, defaultAllDayReminders)
/** The default reminders a task in [listId] takes — the one rule for which default applies. */
fun defaultRemindersFor(listId: Long, allDay: Boolean): List<TaskReminder> =
(if (allDay) allDayReminderLeadsFor(listId) else reminderLeadsFor(listId)).map { it.asTaskReminder() }
}
/** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */
@@ -130,16 +138,18 @@ class SettingsPrefs @Inject constructor(
dynamicColor = p[DYNAMIC_COLOR] ?: true,
defaultListId = p[DEFAULT_LIST_ID]?.takeIf { it > 0 },
// The single lead of earlier versions carries over until a list is saved.
defaultReminderMinutes = p[DEFAULT_REMINDERS]?.let(::parseMinutes)
?: listOf(p[REMINDER_LEAD] ?: 0),
defaultAllDayReminderMinutes = p[DEFAULT_ALL_DAY_REMINDERS]?.let(::parseMinutes) ?: listOf(0),
defaultReminders = p[DEFAULT_REMINDERS]?.let(::parseLeads)
?: listOf(ReminderLead(p[REMINDER_LEAD] ?: 0)),
defaultAllDayReminders = p[DEFAULT_ALL_DAY_REMINDERS]?.let(::parseLeads)?.inWholeDays()
?: listOf(ReminderLead(0)),
allDayReminderMinuteOfDay = p[ALL_DAY_REMINDER_MINUTE]?.takeIf { it in 0 until 24 * 60 }
?: DEFAULT_ALL_DAY_REMINDER_MINUTE,
remindersEnabled = p[REMINDERS_ENABLED] ?: true,
showAddSubtaskRow = p[SHOW_ADD_SUBTASK_ROW] ?: true,
bottomAddBar = p[BOTTOM_ADD_BAR] ?: false,
perListReminderOverride = reminderCodec.parse(p[LIST_REMINDER_OVERRIDE]),
perListAllDayReminderOverride = reminderCodec.parse(p[LIST_ALL_DAY_REMINDER_OVERRIDE]),
perListReminderOverride = reminderCodec.parseLeads(p[LIST_REMINDER_OVERRIDE]),
perListAllDayReminderOverride = reminderCodec.parseLeads(p[LIST_ALL_DAY_REMINDER_OVERRIDE])
.mapValues { it.value.inWholeDays() },
defaultEditFields = p[DEFAULT_EDIT_FIELDS].orEmpty()
.mapNotNull { name -> runCatching { TaskFormField.valueOf(name) }.getOrNull() }
.toSet(),
@@ -185,12 +195,12 @@ class SettingsPrefs @Inject constructor(
if (id == null) it.remove(DEFAULT_LIST_ID) else it[DEFAULT_LIST_ID] = id
}
suspend fun setDefaultReminderMinutes(minutes: List<Int>) = dataStore.edit {
it[DEFAULT_REMINDERS] = minutes.normalizeReminders().joinToString(",")
suspend fun setDefaultReminders(leads: List<ReminderLead>) = dataStore.edit {
it[DEFAULT_REMINDERS] = leads.normalizeReminders().joinToString(",") { lead -> lead.toToken() }
}
suspend fun setDefaultAllDayReminderMinutes(minutes: List<Int>) = dataStore.edit {
it[DEFAULT_ALL_DAY_REMINDERS] = minutes.normalizeReminders().joinToString(",")
suspend fun setDefaultAllDayReminders(leads: List<ReminderLead>) = dataStore.edit {
it[DEFAULT_ALL_DAY_REMINDERS] = leads.normalizeReminders().joinToString(",") { lead -> lead.toToken() }
}
suspend fun setAllDayReminderMinuteOfDay(minuteOfDay: Int) =
@@ -243,16 +253,16 @@ class SettingsPrefs @Inject constructor(
/** Set (or clear, via [ReminderOverride.Inherit]) a list's reminder override. */
suspend fun setListReminderOverride(listId: Long, override: ReminderOverride) = dataStore.edit { p ->
val current = reminderCodec.parse(p[LIST_REMINDER_OVERRIDE]).toMutableMap()
val current = reminderCodec.parseLeads(p[LIST_REMINDER_OVERRIDE]).toMutableMap()
current.applyReminderOverride(listId, override)
p[LIST_REMINDER_OVERRIDE] = reminderCodec.serialize(current)
p[LIST_REMINDER_OVERRIDE] = reminderCodec.serializeLeads(current)
}
/** Set (or clear) a list's all-day reminder override. */
suspend fun setListAllDayReminderOverride(listId: Long, override: ReminderOverride) = dataStore.edit { p ->
val current = reminderCodec.parse(p[LIST_ALL_DAY_REMINDER_OVERRIDE]).toMutableMap()
val current = reminderCodec.parseLeads(p[LIST_ALL_DAY_REMINDER_OVERRIDE]).toMutableMap()
current.applyReminderOverride(listId, override)
p[LIST_ALL_DAY_REMINDER_OVERRIDE] = reminderCodec.serialize(current)
p[LIST_ALL_DAY_REMINDER_OVERRIDE] = reminderCodec.serializeLeads(current)
}
suspend fun setHiddenFromSmartLists(listId: Long, hidden: Boolean) = dataStore.edit { p ->
@@ -330,6 +340,13 @@ class SettingsPrefs @Inject constructor(
*/
private val reminderCodec = ReminderOverrideCodec.DEFAULT
/** `5,30` → [5, 30]; an empty string is an explicit "no reminder". */
private fun parseMinutes(stored: String): List<Int> =
stored.split(',').mapNotNull { it.trim().toIntOrNull()?.takeIf { m -> m >= 0 } }.normalizeReminders()
/** `s5,30` → [5 before start, 30 before due]; an empty string is an explicit "no reminder". */
private fun parseLeads(stored: String): List<ReminderLead> =
stored.split(',').mapNotNull { ReminderLead.parse(it)?.takeIf { lead -> lead.minutes >= 0 } }.normalizeReminders()
/**
* All-day defaults as whole days. Before 1.2 an all-day default could be hours
* before the all-day time; a lead that is not whole days now names a time of day.
*/
private fun List<ReminderLead>.inWholeDays(): List<ReminderLead> =
map { it.asTaskReminder().inWholeDays().asReminderLead() }.normalizeReminders()
@@ -1,8 +1,9 @@
package de.jeanlucmakiola.agendula.data.reminders
import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
import de.jeanlucmakiola.agendula.domain.AllDayReminder
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.domain.calendarDate
import java.time.LocalTime
import java.time.ZoneId
@@ -36,31 +37,53 @@ internal object ReminderPlanner {
// One reminder per *occurrence* and lead: a recurring series yields a row
// per occurrence, all sharing a taskId, so this is a Set rather than a
// taskId-keyed Map.
.filter { !it.isClosed && it.due != null }
.filter { !it.isClosed && (it.due != null || it.start != null) }
.flatMap { task ->
// Reminders set on the task itself win — every one of them, not just
// the one the editor shows; otherwise the task's list may override the
// global lead, or opt out entirely (override = null). All-day tasks
// have their own day-scale defaults.
val reminders = perTask[task.taskId].orEmpty().ifEmpty {
val leads = if (task.isAllDay) settings.allDayReminderLeadsFor(task.listId)
else settings.reminderLeadsFor(task.listId)
leads.map { TaskReminder(it) }
}
reminders.map { reminder ->
// A stored reminder says what it counts back from. Ours are always
// before due, but an imported dmfs alarm or another client's can be
// before *start*.
val anchor = if (reminder.fromStart) task.start ?: task.due!! else task.due!!
// Reminders set on the task itself win — every one of them;
// otherwise the task's list may override the global lead, or opt
// out entirely (override = null). All-day tasks have their own
// day-scale defaults.
//
// ⚠️ Present-but-empty is "none", not "the default": the store's
// answer for a task whose alarms another client turned off. A
// default here would keep ringing for what the user switched off.
// A default counts only from a date the task has; its own reminders
// stand in each date for the other when the task lacks it.
val own = perTask[task.taskId]
val reminders = own ?: settings.defaultRemindersFor(task.listId, task.isAllDay)
.filter { (if (it.fromStart) task.start else task.due) != null }
val created = task.created?.toEpochMilliseconds()
reminders.mapNotNull { reminder ->
val anchor = if (reminder.fromStart) task.start ?: task.due!! else task.due ?: task.start!!
val trigger = triggerAt(
anchor,
task.isAllDay,
reminder.minutesBefore,
settings.allDayReminderMinuteOfDay,
zone,
)
// A default never rings for a moment before the task existed:
// that is a task saved after its default had passed, not a
// reminder missed while the device was off.
if (own == null && created != null && trigger < created) return@mapNotNull null
// Dismissed on another client already: firing it here within
// the missed-reminder grace would ring for something done with.
//
// ⚠️ An all-day reminder without a time of its own is compared
// from the start of its day, not from this device's time of
// day: other clients ring it at midnight, or at their own hour,
// so a dismissal at 07:00 must still silence the 09:00 this
// device would ring it at.
val earliest = if (task.isAllDay && reminder.allDay.minuteOfDay == null) {
triggerAt(anchor, true, reminder.minutesBefore, 0, zone)
} else {
trigger
}
val acknowledged = reminder.acknowledgedAt?.let { earliest <= it.toEpochMilliseconds() } == true
if (acknowledged) return@mapNotNull null
ScheduledReminder(
taskId = task.taskId,
triggerAt = triggerAt(
anchor,
task.isAllDay,
reminder.minutesBefore,
settings.allDayReminderMinuteOfDay,
zone,
),
triggerAt = trigger,
occurrenceStart = task.occurrenceStart?.toEpochMilliseconds() ?: ScheduledReminder.NO_OCCURRENCE,
)
}
@@ -80,8 +103,9 @@ internal object ReminderPlanner {
*
* ⚠️ An all-day anchor is UTC midnight of its date (see `AllDayTime.kt`), a
* storage convention and not a moment — firing off it rang at 02:00 in
* Berlin and the evening before in New York. It is read as its calendar date
* at [allDayMinuteOfDay] local time instead, and the lead counts back from that.
* Berlin and the evening before in New York. It is read as its calendar
* date instead, and the lead as [TaskReminder.allDay]: whole days ring at
* [allDayMinuteOfDay] local time, anything else at the time it names.
*/
fun triggerAt(
anchor: Instant,
@@ -90,12 +114,11 @@ internal object ReminderPlanner {
allDayMinuteOfDay: Int,
zone: ZoneId,
): Long {
val base = if (allDay) {
val time = LocalTime.of(allDayMinuteOfDay / 60 % 24, allDayMinuteOfDay % 60)
anchor.calendarDate(allDay = true).atTime(time).atZone(zone).toInstant().toEpochMilli()
} else {
anchor.toEpochMilliseconds()
}
return base - leadMinutes.coerceAtLeast(0) * 60_000L
if (!allDay) return anchor.toEpochMilliseconds() - leadMinutes.coerceAtLeast(0) * 60_000L
val (days, minuteOfDay) = AllDayReminder.of(leadMinutes)
val minute = minuteOfDay ?: allDayMinuteOfDay
val time = LocalTime.of(minute / 60 % 24, minute % 60)
return anchor.calendarDate(allDay = true).minusDays(days.toLong())
.atTime(time).atZone(zone).toInstant().toEpochMilli()
}
}
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.domain.TaskReminder
import android.content.ContentProviderOperation
import android.content.ContentResolver
import android.content.ContentUris
@@ -85,6 +86,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
@@ -127,9 +149,10 @@ class AndroidTasksDataSource @Inject constructor(
// --- writes ---------------------------------------------------------------
override fun insertTask(form: TaskForm): Long {
override fun insertTask(form: TaskForm, reminders: List<TaskReminder>?): Long {
val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id)
// One batch, so a refused tag leaves no task behind for a retry to duplicate.
// One batch, so a refused tag or alarm leaves no task behind for a retry
// to duplicate.
val authority = authority()
val ops = arrayListOf(
ContentProviderOperation.newInsert(TasksContract.tasksUri(authority))
@@ -142,20 +165,35 @@ class AndroidTasksDataSource @Inject constructor(
.withValueBackReference(Properties.TASK_ID, 0)
.build()
}
reminders.orEmpty().forEach { reminder ->
ops += ContentProviderOperation.newInsert(TasksContract.propertiesUri(authority))
.withValues(
TaskWriteMapper.alarmValues(0, reminder.minutesBefore, reminder.fromStart)
.toContentValues().apply { remove(Properties.TASK_ID) },
)
.withValueBackReference(Properties.TASK_ID, 0)
.build()
}
val results = runCatching { resolver.applyBatch(authority, ops) }
.getOrElse { throw TaskWriteFailedException("insert task", it) }
return results.firstOrNull()?.uri?.lastPathSegment?.toLongOrNull()
?: throw TaskWriteFailedException("insert task: no id")
}
override fun updateTask(taskId: Long, form: TaskForm) {
override fun updateTask(taskId: Long, form: TaskForm, reminders: List<TaskReminder>?) {
val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id)
val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("update task $taskId")
setCategories(taskId, form.tags)
writeReminders(taskId, reminders)
}
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) {
private fun writeReminders(taskId: Long, reminders: List<TaskReminder>?) {
if (reminders == null) return
setReminders(taskId, reminders)
}
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) {
val occurrence = task(seriesId, occurrenceStart)
val anchors = resolver.query(
taskUri(authority(), seriesId), arrayOf(Tasks.DTSTART, Tasks.DUE), null, null, null,
@@ -175,6 +213,7 @@ class AndroidTasksDataSource @Inject constructor(
val rows = resolver.update(taskUri(authority(), seriesId), values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("update series $seriesId")
setCategories(seriesId, form.tags)
writeReminders(seriesId, reminders)
}
/**
@@ -183,10 +222,10 @@ class AndroidTasksDataSource @Inject constructor(
* task. Per-occurrence edits past the split point are dropped rather than
* carried over — the provider offers no way to re-parent an exception.
*/
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm): Long {
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?): Long {
val spec = seriesSpec(seriesId) ?: throw TaskWriteFailedException("split series $seriesId")
if (spec.rrule == null || occurrenceStart <= spec.anchor) {
updateSeries(seriesId, occurrenceStart, form)
updateSeries(seriesId, occurrenceStart, form, reminders)
return seriesId
}
// COUNT counts before EXDATE is applied (RFC 5545 §3.8.5.3).
@@ -196,12 +235,12 @@ class AndroidTasksDataSource @Inject constructor(
).size
endSeriesBefore(seriesId, spec, occurrenceStart)
val rule = form.rrule?.let { if (it == spec.rrule) TaskFormWriter.remainingCount(it, spent) else it }
return insertTask(form.copy(rrule = rule, percentComplete = null))
return insertTask(form.copy(rrule = rule, percentComplete = null), reminders)
}
override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) {
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)
}
@@ -262,7 +301,23 @@ class AndroidTasksDataSource @Inject constructor(
resolver.delete(TasksContract.instanceUri(authority(), instanceId), null, null)
}
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) {
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) {
if (reminders == null) return writeInstance(taskId, occurrenceStart, form)
// ⚠️ Written to the series, and then put back. Forking an occurrence
// copies the task's *current* properties onto the new override row, so
// setting the alarm beforehand is the only way to carry it across — and
// leaving it there would silently give every other occurrence in the
// series the reminder the user set on one of them.
val seriesReminders = alarmRows(taskId).map { it.second }
writeReminders(taskId, reminders)
try {
writeInstance(taskId, occurrenceStart, form)
} finally {
setReminders(taskId, seriesReminders)
}
}
private fun writeInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) {
val instanceId = instanceIdFor(taskId, occurrenceStart)
?: throw TaskWriteFailedException("update instance $taskId@$occurrenceStart: no such occurrence")
val values = TaskWriteMapper.instanceValues(form, ZoneId.systemDefault().id)
@@ -315,21 +370,6 @@ class AndroidTasksDataSource @Inject constructor(
}
}
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) {
val uri = TasksContract.propertiesUri(authority())
// Replace rather than update: the provider's AlarmHandler re-validates the
// whole row on every update, so a partial edit throws. Only the editable
// (last before-due) row goes — a start-relative or additional alarm
// another client wrote is not ours to drop.
alarmRows(taskId).lastOrNull { !it.second.fromStart }?.let { (propertyId, _) ->
resolver.delete(uri, "${Properties.PROPERTY_ID} = ?", arrayOf(propertyId.toString()))
}
if (minutesBeforeDue != null) {
resolver.insert(uri, TaskWriteMapper.alarmValues(taskId, minutesBeforeDue).toContentValues())
?: throw TaskWriteFailedException("set alarm for task $taskId")
}
}
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) {
val uri = TasksContract.propertiesUri(authority())
resolver.delete(
@@ -346,9 +386,6 @@ class AndroidTasksDataSource @Inject constructor(
override fun reminders(): Map<Long, List<TaskReminder>> =
readAlarms(null).groupBy({ it.first }) { it.third }
override fun alarms(): Map<Long, TaskReminder> =
reminders().mapNotNull { (id, list) -> list.editable()?.let { id to it } }.toMap()
/** `(property_id, reminder)` for [taskId]'s alarm rows, in stored order. */
private fun alarmRows(taskId: Long): List<Pair<Long, TaskReminder>> =
readAlarms(taskId).map { it.second to it.third }
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskForm
@@ -34,23 +35,24 @@ class ModeRoutingTasksDataSource(
override fun tasks(query: TaskQuery): List<Task> = active().tasks(query)
override fun task(taskId: Long): Task? = active().task(taskId)
override fun task(taskId: Long, occurrenceStart: Instant?): Task? = active().task(taskId, occurrenceStart)
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) =
active().updateSeries(seriesId, occurrenceStart, form)
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm): Long =
active().splitSeries(seriesId, occurrenceStart, form)
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) =
active().updateSeries(seriesId, occurrenceStart, form, reminders)
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?): Long =
active().splitSeries(seriesId, occurrenceStart, form, reminders)
override fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant) =
active().deleteOccurrence(seriesId, occurrenceStart)
override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) =
active().deleteFollowing(seriesId, occurrenceStart)
override fun subtasks(parentTaskId: Long): List<Task> = active().subtasks(parentTaskId)
override fun insertTask(form: TaskForm): Long = active().insertTask(form)
override fun updateTask(taskId: Long, form: TaskForm) = active().updateTask(taskId, form)
override fun subtaskIds(parentTaskId: Long): List<Long> = active().subtaskIds(parentTaskId)
override fun deleteWithSubtasks(taskId: Long) = active().deleteWithSubtasks(taskId)
override fun insertTask(form: TaskForm, reminders: List<TaskReminder>?): Long = active().insertTask(form, reminders)
override fun updateTask(taskId: Long, form: TaskForm, reminders: List<TaskReminder>?) =
active().updateTask(taskId, form, reminders)
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) =
active().updateInstance(taskId, occurrenceStart, form)
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) =
active().updateInstance(taskId, occurrenceStart, form, reminders)
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) = active().setAlarm(taskId, minutesBeforeDue)
override fun alarms(): Map<Long, TaskReminder> = active().alarms()
override fun reminders(): Map<Long, List<TaskReminder>> = active().reminders()
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) =
active().setReminders(taskId, reminders)
@@ -0,0 +1,101 @@
package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.data.reminders.ReminderPlanner
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskReminder
import java.time.ZoneId
import kotlin.time.Instant
internal object StoredReminders {
/**
* The reminders to store for [form], given the task as it was.
*
* A date the task gets in this write brings the list's or the app's
* default for it *as the task's own reminders*. The default is this
* device's setting, which nothing else can see; stored on the task, it
* travels with it to the server and fires on every client. The cost is
* tasks.org's: changing the default later does not reach the tasks that
* already have it.
*
* A task getting its first date takes the defaults only when the form
* names no reminder; one that already had a date takes those of the new
* date that no reminder of the form counts from. Otherwise the form is
* kept, so an edit that clears every reminder means "use the default
* here" as before.
*
* The form's reminders are already of its kind, timed or all-day — see
* [switched]. Reminders the editor [chose][TaskForm.remindersChosen] are
* kept as they are: it showed these defaults already.
*/
fun of(form: TaskForm, current: Task?, settings: Settings, now: Long, zone: ZoneId): List<TaskReminder> {
val wanted = form.reminders.map { it.lead }.distinct()
if (form.remindersChosen) return wanted
return withDefaults(form, current?.start, current?.due, settings, now, zone)
}
/** [of] for a task whose dates were [start] and [due], whatever the form chose. */
fun withDefaults(
form: TaskForm,
start: Instant?,
due: Instant?,
settings: Settings,
now: Long,
zone: ZoneId,
): List<TaskReminder> {
val wanted = form.reminders.map { it.lead }.distinct()
val had = anchorsOf(start, due)
val gained = anchorsOf(form.start, form.due) - had
if (gained.isEmpty()) return wanted
if (had.isEmpty() || wanted.isEmpty()) return wanted.ifEmpty { defaultsFor(form, settings, now, zone) }
val named = wanted.map { it.fromStart }.toSet()
return wanted + defaultsFor(form, settings, now, zone).filter { it.fromStart in gained && it.fromStart !in named }
}
/**
* [reminders] for [form] once it has switched between timed and all-day.
* Reminders that are exactly the old kind's default become the new kind's;
* the rest keep what still means something: whole days, rounded to them on
* an all-day task.
*/
fun switched(
reminders: List<TaskReminder>,
form: TaskForm,
settings: Settings,
now: Long,
zone: ZoneId,
): List<TaskReminder> {
if (reminders.isEmpty()) return reminders
if (reminders.toSet() == settings.defaultRemindersFor(form.listId, !form.isAllDay).toSet()) {
return defaultsFor(form, settings, now, zone)
}
val kept = if (form.isAllDay) {
reminders.map { it.inWholeDays() }
} else {
reminders.filter { it.allDay.minuteOfDay == null }
}
return kept.distinct().ifEmpty { defaultsFor(form, settings, now, zone) }
}
/** The dates a reminder can count from, as [TaskReminder.fromStart]. */
private fun anchorsOf(start: Any?, due: Any?): Set<Boolean> =
setOfNotNull(true.takeIf { start != null }, false.takeIf { due != null })
/**
* The defaults [form] takes: those counting from a date it has. A one-off
* task leaves out what is already past — a start of "now" would ring at once.
*/
private fun defaultsFor(form: TaskForm, settings: Settings, now: Long, zone: ZoneId): List<TaskReminder> =
settings.defaultRemindersFor(form.listId, form.isAllDay).filter { reminder ->
val anchor = (if (reminder.fromStart) form.start else form.due) ?: return@filter false
form.rrule != null || ReminderPlanner.triggerAt(
anchor,
form.isAllDay,
reminder.minutesBefore,
settings.allDayReminderMinuteOfDay,
zone,
) >= now
}
}
@@ -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"
}
/**
@@ -164,6 +175,9 @@ object TasksContract {
/** `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()
@@ -3,26 +3,9 @@ package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.TaskReminder
import kotlin.time.Instant
/**
* A stored reminder: how long before, and what it counts back from.
*
* [fromStart] matters because both stores can hold a `START`-referenced alarm —
* the dmfs import preserves one, and an external provider's other clients write
* them — while Agendula's own UI only ever sets a before-due lead. Collapsing it
* to a number here is what silently fired those reminders off the wrong anchor.
*/
data class TaskReminder(val minutesBefore: Int, val fromStart: Boolean = false)
/**
* The one reminder Agendula's single-reminder editor shows and writes: the last
* before-due one, which is where a write lands, so it stays the same one across
* saves. Anything else a task carries — a start-relative alarm, or the extra ones
* another client or the dmfs import brought — is left to ride along.
*/
fun List<TaskReminder>.editable(): TaskReminder? = lastOrNull { !it.fromStart }
/** What to fetch from the provider. Smart-list date logic is applied above this. */
data class TaskQuery(
val listId: Long? = null,
@@ -46,8 +29,38 @@ interface TasksDataSource {
fun task(taskId: Long, occurrenceStart: Instant?): Task? = task(taskId)
fun subtasks(parentTaskId: Long): List<Task>
fun insertTask(form: TaskForm): Long
fun updateTask(taskId: Long, form: TaskForm)
/**
* The task rows directly under [parentTaskId]: one id per subtask, its series
* master when it recurs, whether or not any occurrence falls in the window
* [subtasks] expands.
*/
fun subtaskIds(parentTaskId: Long): List<Long> =
subtasks(parentTaskId).map { it.seriesId ?: it.taskId }.distinct().filter { it != parentTaskId }
/**
* Delete [taskId] and its subtasks, all the way down; left behind they would
* hang off a tombstone. Each task once, so a parent loop an import brought in
* ends instead of recursing forever.
*/
fun deleteWithSubtasks(taskId: Long) {
val seen = HashSet<Long>()
fun delete(id: Long) {
if (!seen.add(id)) return
subtaskIds(id).forEach(::delete)
deleteTask(id)
}
delete(taskId)
}
/**
* The write paths take the task's reminders in the same write as the task
* itself, so a sync can never upload one without the other.
*
* Each takes the task's whole set of [reminders], before-start and
* before-due alike, and `null` leaves the stored ones alone.
*/
fun insertTask(form: TaskForm, reminders: List<TaskReminder>? = null): Long
fun updateTask(taskId: Long, form: TaskForm, reminders: List<TaskReminder>? = null)
/**
* Update a single occurrence of a recurring task, addressed by the task row and
@@ -60,27 +73,19 @@ interface TasksDataSource {
* instance row id keeps this seam independent of any one store's row
* numbering; External mode maps it back to an instance row itself.
*/
fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm)
/**
* Set (or clear, with `null`) the task's [editable] reminder lead, stored as an
* Alarm property row. Every other reminder on the task is left untouched. The
* provider never fires it — [de.jeanlucmakiola.agendula.data.reminders
* .ReminderScheduler] does — but persisting it here is what syncs the lead and
* shares it with other OpenTasks clients.
*/
fun setAlarm(taskId: Long, minutesBeforeDue: Int?)
fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>? = null)
/**
* Edit the whole series [seriesId] through its occurrence at [occurrenceStart]:
* content from [form], and the series moved by however far that occurrence moved.
*/
fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm)
fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>? = null)
/**
* "This and following": end [seriesId] before [occurrenceStart] and continue
* from there as a new series built from [form]. Returns the new series' id.
*/
fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm): Long
fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>? = null): Long
/** Delete one generated occurrence of [seriesId] (an `EXDATE`). */
fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant)
@@ -89,14 +94,15 @@ interface TasksDataSource {
fun deleteFollowing(seriesId: Long, occurrenceStart: Instant)
/** Replace every reminder on [taskId] with [reminders], in order. */
/** Make [taskId]'s reminders exactly [reminders]. A synced task is owed a PUT for it. */
fun setReminders(taskId: Long, reminders: List<TaskReminder>)
/** Every task's reminders, by task id, in stored order. One query, for the scheduler. */
fun reminders(): Map<Long, List<TaskReminder>> = alarms().mapValues { listOf(it.value) }
/** Every task's [editable] reminder, by task id. */
fun alarms(): Map<Long, TaskReminder>
/**
* Every task's reminders, by task id, in stored order. One query, for the
* scheduler. A task missing from the map takes the default; one present
* with an empty list has none (see `TaskEntity.remindersOff`).
*/
fun reminders(): Map<Long, List<TaskReminder>>
/**
* Every task in [listId] read from the **`tasks` table**, for export. Masters,
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.domain.SavedFilter
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskDetail
@@ -82,13 +83,6 @@ interface TasksRepository {
scope: RecurringScope = RecurringScope.AllOccurrences,
)
/**
* The per-task reminder lead in minutes before due, or `null` if the task has
* none (in which case the list's / global setting applies). Read when the edit
* form loads so saving can't silently drop it.
*/
suspend fun reminderFor(taskId: Long): Int?
/** Every reminder stored on the task, in order — including start-relative ones. */
suspend fun remindersFor(taskId: Long): List<TaskReminder>
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.floret.time.DayWindow
@@ -21,6 +22,7 @@ import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
@@ -109,11 +111,18 @@ class TasksRepositoryImpl @Inject constructor(
override suspend fun createTask(form: TaskForm): Long =
withContext(io) {
val id = dataSource.insertTask(form)
writeReminders(id, form)
id
dataSource.insertTask(form, storedRemindersFor(form, current = null))
}
private suspend fun storedRemindersFor(form: TaskForm, current: Task?): List<TaskReminder> =
StoredReminders.of(
form,
current,
settingsPrefs.settings.first(),
Clock.System.now().toEpochMilliseconds(),
ZoneId.systemDefault(),
)
override suspend fun duplicateTask(taskId: Long, occurrenceStart: Instant?): Long =
withContext(io) {
val task = checkNotNull(dataSource.task(taskId, occurrenceStart)) { "Task $taskId is gone" }
@@ -121,6 +130,9 @@ class TasksRepositoryImpl @Inject constructor(
}
private fun copyOf(task: Task, parentId: Long?, depth: Int): Long {
// In the same write as the copy: inserted first and given its reminders
// after, a sync landing in between uploads the copy without them.
val reminders = dataSource.reminders()[task.taskId].orEmpty().map { it.lead }
val id = dataSource.insertTask(
TaskForm(
title = task.title,
@@ -135,9 +147,8 @@ class TasksRepositoryImpl @Inject constructor(
url = task.url,
tags = task.tags,
),
reminders,
)
val reminders = dataSource.reminders()[task.taskId].orEmpty()
if (reminders.isNotEmpty()) dataSource.setReminders(id, reminders)
if (depth < MAX_COPY_DEPTH) {
SeriesCollapse.visible(dataSource.subtasks(task.taskId))
.filter { it.taskId != task.taskId && it.status != TaskStatus.CANCELLED }
@@ -146,9 +157,6 @@ class TasksRepositoryImpl @Inject constructor(
return id
}
override suspend fun reminderFor(taskId: Long): Int? =
withContext(io) { runCatching { dataSource.alarms()[taskId]?.minutesBefore }.getOrNull() }
override suspend fun remindersFor(taskId: Long): List<TaskReminder> =
withContext(io) { runCatching { dataSource.reminders()[taskId].orEmpty() }.getOrDefault(emptyList()) }
@@ -172,18 +180,17 @@ class TasksRepositoryImpl @Inject constructor(
val seen = current?.lastModified
if (seen != null && seen != expectedLastModified) throw TaskConflictException(taskId)
}
val reminders = storedRemindersFor(form, current)
val seriesId = current?.seriesId
val occurrence = current?.occurrenceStart
if (seriesId != null && occurrence != null) {
when (scope) {
RecurringScope.AllOccurrences -> {
writeReminders(seriesId, form)
dataSource.updateSeries(seriesId, occurrence, form)
dataSource.updateSeries(seriesId, occurrence, form, reminders)
return@withContext
}
RecurringScope.ThisAndFollowing -> {
val created = dataSource.splitSeries(seriesId, occurrence, form)
writeReminders(created, form)
dataSource.splitSeries(seriesId, occurrence, form, reminders)
return@withContext
}
RecurringScope.ThisOccurrence -> Unit
@@ -191,31 +198,11 @@ class TasksRepositoryImpl @Inject constructor(
}
// A recurring task's start/due are one occurrence's resolved times, so
// writing them back to the task row would re-anchor the whole series.
// Going through the occurrence forks an override instead.
// Going through the occurrence forks an override instead, which takes
// the reminders on its own row and leaves the series' alone.
val instance = current?.takeIf { it.isRecurring }?.occurrenceStart
if (instance == null) {
writeReminders(taskId, form)
dataSource.updateTask(taskId, form)
return@withContext
}
// ⚠️ Written to the series, and then put back. Forking an occurrence
// copies the task's *current* properties onto the new override row,
// so setting the alarm beforehand is the only way to carry it
// across — and leaving it there would silently give every other
// occurrence in the series the reminder the user set on one of them.
val seriesReminders = dataSource.reminders()[taskId].orEmpty()
writeReminders(taskId, form)
try {
dataSource.updateInstance(taskId, instance, form)
} finally {
dataSource.setReminders(taskId, seriesReminders)
}
}
/** The form's before-due reminders; a start-relative one from elsewhere is kept as it was. */
private fun writeReminders(taskId: Long, form: TaskForm) {
val kept = dataSource.reminders()[taskId].orEmpty().filter { it.fromStart }
dataSource.setReminders(taskId, kept + form.reminders.distinct().sorted().map { TaskReminder(it) })
if (instance == null) dataSource.updateTask(taskId, form, reminders)
else dataSource.updateInstance(taskId, instance, form, reminders)
}
override suspend fun setCancelled(taskId: Long, occurrenceStart: Instant?, cancelled: Boolean) =
@@ -248,7 +235,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
@@ -256,18 +243,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) }
@@ -0,0 +1,228 @@
package de.jeanlucmakiola.agendula.data.tasks.ical
import de.jeanlucmakiola.agendula.data.reminders.ReminderPlanner
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.domain.ical.ICalComponent
import de.jeanlucmakiola.agendula.domain.ical.ICalParam
import de.jeanlucmakiola.agendula.domain.ical.ICalParser
import de.jeanlucmakiola.agendula.domain.ical.ICalProperty
import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
import de.jeanlucmakiola.agendula.domain.DAY_MINUTES
import de.jeanlucmakiola.agendula.domain.MIN_ALL_DAY_LEAD
import kotlin.math.roundToLong
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
/**
* `VALARM` ↔ [TaskAlarmEntity].
*
* ## What is claimed
*
* Only an alarm Agendula can fire exactly as written becomes a reminder row.
* Everything else stays in the task's residue and round-trips untouched — it
* still fires in the client that wrote it, just not here:
*
* - `ACTION` other than `DISPLAY`/`AUDIO`;
* - Apple's default alarms (`X-APPLE-DEFAULT-ALARM:TRUE`). They are that Mac's
* preference, not task data: claimed, they would come back to the Mac as an
* explicit alarm without the flag after the first edit here;
* - location alarms (`X-APPLE-PROXIMITY`, RFC 9074 `PROXIMITY`, a nested
* component) and Apple's `19760401T005545Z` sentinel trigger they carry;
* - repeating alarms (`REPEAT`/`DURATION`) and RFC 9074 snoozes (`RELATED-TO`);
* - a trigger *after* its anchor — on an all-day task, after the end of its
* day. The planner coerces a negative lead to 0, so it would fire at the
* wrong time;
* - an absolute trigger on a recurring task, or on one with no `DUE` to count
* back from.
*
* ## What "unchanged" means
*
* A claimed row keeps the `VALARM` it was read from in [TaskAlarmEntity.raw].
* On write, the row is "unchanged" when that original still [resolve]s to the
* row's lead against the task *as it is now* — so `-PT15M` and `-P0DT0H15M0S`
* are the same, a relative alarm survives a due-date edit, and an absolute one
* stops matching once `DUE` moves. An unchanged row is re-emitted verbatim; a
* changed one is authored fresh under its existing uid.
*
* ## Anchors
*
* `RELATED=END` counts from `DUE`. `RELATED=START` (the default) counts from
* `DTSTART` — except when `DTSTART` equals `DUE`, or when there is no
* `DTSTART` but there is a `DUE`. Apple writes the latter shape (against §3.8.6.3,
* which wants `DTSTART` present).
*
* ## All-day tasks
*
* A relative trigger counts from the date's midnight, so the lead is the
* reminder as [de.jeanlucmakiola.agendula.domain.TaskReminder.allDay] reads it:
* `-P1D` is a day before at this device's all-day time, which this mapper cannot
* see, and `PT9H` is 09:00 on the day. An absolute trigger has no zone to read
* a time of day in, so it rounds to the nearest whole day.
*/
object AlarmCodec {
/** Apple's placeholder trigger for location alarms. Never a real time. */
private const val APPLE_SENTINEL = "19760401T005545Z"
/** What a `VALARM` means to the scheduler. */
data class Lead(val minutesBefore: Int, val reference: AlarmReference)
/** A `VALARM` as a reminder row — `taskId` is the caller's — or null if it is not ours to claim. */
fun fromVAlarm(alarm: ICalComponent, task: TaskEntity): TaskAlarmEntity? {
val lead = resolve(alarm, task) ?: return null
val raw = ICalSerializer.serialize(alarm)
// Too large to keep verbatim means it could not be re-emitted, and an
// alarm that is authored fresh on every write loses what made it large.
if (raw.toByteArray(Charsets.UTF_8).size > VTodoMapper.MAX_RESIDUE_BYTES) return null
val uid = (alarm.property("X-WR-ALARMUID") ?: alarm.property("UID"))?.value?.trim()?.takeIf { it.isNotEmpty() }
val base = TaskAlarmEntity(
taskId = 0,
minutesBefore = lead.minutesBefore,
reference = lead.reference,
raw = raw,
)
return if (uid == null) base else base.copy(uid = uid)
}
/** The lead [alarm] stands for on [task], or null when it is not claimable. */
fun resolve(alarm: ICalComponent, task: TaskEntity): Lead? {
val action = alarm.property("ACTION")?.value?.trim()?.uppercase()
if (action != "DISPLAY" && action != "AUDIO") return null
if (alarm.property("X-APPLE-DEFAULT-ALARM")?.value?.trim().equals("TRUE", ignoreCase = true)) return null
val excluded = listOf("X-APPLE-PROXIMITY", "PROXIMITY", "REPEAT", "DURATION", "RELATED-TO")
if (excluded.any { alarm.property(it) != null }) return null
if (alarm.components.isNotEmpty()) return null
val trigger = alarm.properties("TRIGGER").singleOrNull() ?: return null
val value = trigger.value.trim()
if (value.startsWith(APPLE_SENTINEL)) return null
val absolute = trigger.param("VALUE").equals("DATE-TIME", ignoreCase = true) ||
(!value.startsWith("P", ignoreCase = true) && !value.startsWith("-") && !value.startsWith("+"))
return if (absolute) absoluteLead(trigger, task) else relativeLead(trigger, task)
}
private fun relativeLead(trigger: ICalProperty, task: TaskEntity): Lead? {
val seconds = ICalValues.parseDuration(trigger.value) ?: return null
val reference = when {
trigger.param("RELATED").equals("END", ignoreCase = true) ->
if (task.due != null) AlarmReference.DUE else return null
task.dtstart != null && task.dtstart != task.due -> AlarmReference.START
task.due != null -> AlarmReference.DUE
else -> return null
}
val before = -seconds
// A lead in seconds is not one the planner can reproduce.
if (before % 60 != 0L) return null
val minutes = before / 60
val latest = if (task.isAllDay) MIN_ALL_DAY_LEAD.toLong() else 0L
return minutes.takeIf { it in latest..Int.MAX_VALUE }?.let { Lead(it.toInt(), reference) }
}
private fun absoluteLead(trigger: ICalProperty, task: TaskEntity): Lead? {
if (task.rrule != null || task.rdate != null) return null
val due = task.due ?: return null
val at = (ICalValues.parseTime(trigger) as? ICalValues.TimeValue.Timed)
?.takeIf { it.tzid == null } // §3.8.6.3: an absolute trigger MUST be UTC
?.instant ?: return null
val before = (due - at).inWholeSeconds
val minutes = if (task.isAllDay) {
// An all-day DUE is stored as UTC midnight of its date, which is
// the calendar date the trigger counts back from.
roundToDays(before)
} else {
Math.floorDiv(before, 60L)
}
return minutes.toIntOrNull()?.let { Lead(it, AlarmReference.DUE) }
}
/**
* [read] as builds before 1.2 stored it on an all-day task: rounded to whole
* days. Such a row keeps the other client's time of day instead of overwriting it.
*/
private fun readAsWholeDays(read: Lead, task: TaskEntity): Lead? =
if (task.isAllDay && read.minutesBefore % DAY_MINUTES != 0) {
read.copy(minutesBefore = roundToDays(read.minutesBefore * 60L).coerceAtLeast(0).toInt())
} else {
null
}
/** Nearest whole day, in minutes. */
private fun roundToDays(seconds: Long): Long =
(seconds / 86_400.0).roundToLong() * DAY_MINUTES
/** Negative is after the anchor, which is not claimed. */
private fun Long.toIntOrNull(): Int? = takeIf { it in 0..Int.MAX_VALUE }?.toInt()
/**
* [row] as a `VALARM` on [task], or null when it cannot be written: its
* anchor is missing. That row stays on the device — the planner ignores it
* too — because `RELATED=END` without `DUE` is a resource the server
* refuses ([VTodoMapper.validate]).
*
* ⚠️ A row whose anchor the task has lost is written against the other
* date, which is what the planner fires it from. Leaving it out would drop
* it from every other client, and the next download would then take it off
* this device too.
*
* [now] marks an authored alarm `ACKNOWLEDGED` once it is past even the
* planner's missed-reminder grace, so a client that honours RFC 9074 does
* not greet it as a missed alarm. Not sooner: the stamp comes back on the
* next download and would silence the late reminder this device still owes.
*/
fun toVAlarm(row: TaskAlarmEntity, task: TaskEntity, now: Instant): ICalComponent? {
val reference = when {
row.reference == AlarmReference.START && task.dtstart == null -> AlarmReference.DUE
row.reference == AlarmReference.DUE && task.due == null -> AlarmReference.START
else -> row.reference
}
val lead = Lead(row.minutesBefore, reference)
row.raw?.let(::parse)?.let { original ->
val read = resolve(original, task)
if (read == lead || read != null && readAsWholeDays(read, task) == lead) return original
}
val anchor = when (reference) {
AlarmReference.DUE -> task.due
AlarmReference.START -> task.dtstart
} ?: return null
val related = if (reference == AlarmReference.DUE) "END" else "START"
val minutes = row.minutesBefore.coerceAtLeast(if (task.isAllDay) MIN_ALL_DAY_LEAD else 0).toLong()
val seconds = -minutes * 60
val properties = mutableListOf(
ICalProperty("UID", value = row.uid),
ICalProperty("X-WR-ALARMUID", value = row.uid),
ICalProperty("ACTION", value = "DISPLAY"),
ICalProperty("DESCRIPTION", value = ICalValues.escapeText(row.message ?: "Reminder")),
ICalProperty("TRIGGER", listOf(ICalParam("RELATED", related)), ICalValues.formatDuration(seconds)),
)
val recurring = task.rrule != null || task.rdate != null
if (!recurring && anchor + seconds.seconds + ReminderPlanner.MISSED_GRACE_MS.milliseconds < now) {
properties += ICalProperty("ACKNOWLEDGED", value = ICalValues.formatDateTime(now, null))
}
return ICalComponent("VALARM", properties)
}
/**
* What [row] reads back as once it has been to the server and back — not
* always the row itself, because the wire is coarser: a lead past the end
* of its anchor is clamped, and a before-start row on a task without a
* start goes out before-due. A download that brings back exactly this has not changed the
* reminder, and the row's own lead must be kept rather than taken from it.
*
* ⚠️ As authored from the row, not re-emitted from [TaskAlarmEntity.raw]:
* a row rounded to whole days before 1.2 re-emits a time of day it does
* not hold, and would keep its rounded lead against the very alarm that
* carries it.
*/
fun wireLead(row: TaskAlarmEntity, task: TaskEntity): Lead? =
toVAlarm(row.copy(raw = null), task, Instant.DISTANT_PAST)?.let { resolve(it, task) }
private fun parse(raw: String): ICalComponent? =
runCatching { ICalParser.parse(raw) }.getOrNull()?.takeIf { it.name.equals("VALARM", ignoreCase = true) }
}
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.agendula.data.tasks.ical
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
import de.jeanlucmakiola.agendula.domain.TaskStatus
@@ -52,10 +53,11 @@ import kotlin.time.Instant
*
* ## Alarms
*
* `VALARM`s round-trip in the residue and are never authored here. Local
* reminders live in `task_alarms` and are never serialised. The two stores are
* disjoint, so neither can destroy the other. Merging them is a later
* decision, not a silent one.
* A `VALARM` [AlarmCodec] can fire exactly as written is claimed into
* [Mapped.alarms] — rows for `task_alarms`, carrying the original verbatim —
* and [write] authors the task's rows back. Any other `VALARM` (Apple's default
* and location alarms, repeating ones, snoozes) stays in the residue and is
* re-emitted untouched after the authored ones.
*/
object VTodoMapper {
@@ -95,6 +97,8 @@ object VTodoMapper {
val uidWasMissing: Boolean,
/** Residue that exceeded [MAX_RESIDUE_BYTES] and had to be dropped. */
val droppedResidue: Boolean,
/** The claimed `VALARM`s, `taskId` unset. Not counted against the residue cap. */
val alarms: List<TaskAlarmEntity> = emptyList(),
)
// ---------------------------------------------------------------- read
@@ -263,8 +267,18 @@ object VTodoMapper {
.filterIndexed { index, _ -> index !in claimed }
.filterNot { it.name.equals("DTSTAMP", ignoreCase = true) }
// Claimed against the entity as read, so the anchors are the ones the
// rows will be written back against.
val alarms = mutableListOf<TaskAlarmEntity>()
val residueComponents = vtodo.components.filter { component ->
if (!component.name.equals("VALARM", ignoreCase = true)) return@filter true
val row = AlarmCodec.fromVAlarm(component, entity) ?: return@filter true
alarms += row
false
}
val residueText = ICalSerializer.serializeProperties(residueProperties) +
ICalSerializer.serializeAll(vtodo.components)
ICalSerializer.serializeAll(residueComponents)
val tooLarge = residueText.toByteArray(Charsets.UTF_8).size > MAX_RESIDUE_BYTES
return Mapped(
@@ -274,6 +288,7 @@ object VTodoMapper {
parentUid = parentUid,
uidWasMissing = uid.isEmpty(),
droppedResidue = tooLarge,
alarms = alarms,
)
}
@@ -283,12 +298,14 @@ object VTodoMapper {
* Serialises [entity] back to a VTODO.
*
* [parentUid] is the parent's `UID`, which the entity holds only as a row id.
* [now] is the `DTSTAMP`.
* [now] is the `DTSTAMP`. [alarms] are the task's reminder rows; one whose
* anchor the task no longer has is left out (see [AlarmCodec.toVAlarm]).
*/
fun write(
entity: TaskEntity,
parentUid: String? = null,
now: Instant = Clock.System.now(),
alarms: List<TaskAlarmEntity> = emptyList(),
): ICalComponent {
val residue = parseResidue(entity.unknownProperties)
val residueCategories = normalizeTags(residue.properties("CATEGORIES").flatMap(::categoryValues))
@@ -349,7 +366,7 @@ object VTodoMapper {
return ICalComponent(
name = "VTODO",
properties = properties + keptResidue,
components = residue.components,
components = alarms.mapNotNull { AlarmCodec.toVAlarm(it, entity, now) } + residue.components,
)
}
@@ -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,101 @@
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
import kotlin.math.roundToLong
import kotlin.time.Instant
/**
* Lifts claimable `VALARM`s out of a residue written before v3 synced them.
*
* ⚠️ Frozen with [TasksDatabase.MIGRATION_2_3]: a copy of `AlarmCodec`'s claim
* rule as it stood then, not a call into it, so a later change to what the
* codec claims cannot change what this migration produced for an earlier
* upgrader.
*/
internal object AlarmLift {
/** The task columns the claim rule reads. */
data class Task(
val dtstart: Instant?,
val due: Instant?,
val isAllDay: Boolean,
val recurring: Boolean,
)
data class Alarm(val minutesBefore: Int, val reference: AlarmReference, val uid: String?, val raw: String)
/** The lifted alarms and the residue without them, or `null` residue when nothing is left. */
data class Result(val alarms: List<Alarm>, val residue: String?)
private const val DAY_MINUTES = 24 * 60
private const val MAX_RAW_BYTES = 25 * 1024
fun lift(residue: String?, task: Task): Result {
val component = parse(residue) ?: return Result(emptyList(), residue)
val alarms = mutableListOf<Alarm>()
val kept = component.components.filter { sub ->
if (!sub.name.equals("VALARM", ignoreCase = true)) return@filter true
val alarm = claim(sub, task) ?: return@filter true
alarms += alarm
false
}
if (alarms.isEmpty()) return Result(emptyList(), residue)
val rest = ICalSerializer.serializeProperties(component.properties) + ICalSerializer.serializeAll(kept)
return Result(alarms, rest.ifEmpty { null })
}
private fun claim(alarm: ICalComponent, task: Task): Alarm? {
val action = alarm.property("ACTION")?.value?.trim()?.uppercase()
if (action != "DISPLAY" && action != "AUDIO") return null
if (alarm.property("X-APPLE-DEFAULT-ALARM")?.value?.trim().equals("TRUE", ignoreCase = true)) return null
if (listOf("X-APPLE-PROXIMITY", "PROXIMITY", "REPEAT", "DURATION", "RELATED-TO").any { alarm.property(it) != null }) {
return null
}
if (alarm.components.isNotEmpty()) return null
val trigger = alarm.properties("TRIGGER").singleOrNull() ?: return null
val value = trigger.value.trim()
if (value.startsWith("19760401T005545Z")) return null
val absolute = trigger.param("VALUE").equals("DATE-TIME", ignoreCase = true) ||
(!value.startsWith("P", ignoreCase = true) && !value.startsWith("-") && !value.startsWith("+"))
val (minutes, reference) = (if (absolute) absolute(trigger, task) else relative(trigger, task)) ?: return null
val raw = ICalSerializer.serialize(alarm)
if (raw.toByteArray(Charsets.UTF_8).size > MAX_RAW_BYTES) return null
val uid = (alarm.property("X-WR-ALARMUID") ?: alarm.property("UID"))?.value?.trim()?.takeIf { it.isNotEmpty() }
return Alarm(minutes, reference, uid, raw)
}
private fun relative(trigger: ICalProperty, task: Task): Pair<Int, AlarmReference>? {
val seconds = ICalValues.parseDuration(trigger.value) ?: return null
val reference = when {
trigger.param("RELATED").equals("END", ignoreCase = true) ->
if (task.due != null) AlarmReference.DUE else return null
task.dtstart != null && task.dtstart != task.due -> AlarmReference.START
task.due != null -> AlarmReference.DUE
else -> return null
}
val before = -seconds
val minutes = if (task.isAllDay) days(before) else if (before % 60 != 0L) return null else before / 60
return minutes.takeIf { it in 0..Int.MAX_VALUE }?.let { it.toInt() to reference }
}
private fun absolute(trigger: ICalProperty, task: Task): Pair<Int, AlarmReference>? {
if (task.recurring) return null
val due = task.due ?: return null
val at = (ICalValues.parseTime(trigger) as? ICalValues.TimeValue.Timed)
?.takeIf { it.tzid == null }?.instant ?: return null
val before = (due - at).inWholeSeconds
val minutes = if (task.isAllDay) days(before) else Math.floorDiv(before, 60L)
return minutes.takeIf { it in 0..Int.MAX_VALUE }?.let { it.toInt() to AlarmReference.DUE }
}
private fun days(seconds: Long): Long = (seconds / 86_400.0).roundToLong() * DAY_MINUTES
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()
}
}
@@ -8,6 +8,7 @@ import androidx.room.PrimaryKey
import androidx.room.TypeConverters
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
import de.jeanlucmakiola.agendula.domain.TaskStatus
import java.util.UUID
import kotlin.time.Instant
/**
@@ -199,12 +200,31 @@ data class TaskEntity(
* verbatim on write so a round-trip cannot silently lose a field.
*/
@ColumnInfo(name = "unknown_properties") val unknownProperties: String? = null,
// reminders
/**
* Another client removed this task's last alarm, so it has none — not "use
* the default". A task without reminder rows normally takes the list's or
* the app's default, which is right for one that never had any (a client
* that does not write alarms), and wrong for one whose alarms were turned
* off elsewhere: it would keep ringing here. Set by a download that takes
* the last row away; cleared as soon as the task has a reminder again.
*/
@ColumnInfo(name = "reminders_off", defaultValue = "0") val remindersOff: Boolean = false,
)
/** What [TaskAlarmEntity.minutesBefore] counts back from. */
enum class AlarmReference { DUE, START }
/** A reminder lead on a task. Positive [minutesBefore] is *before* [reference]. */
/**
* A reminder lead on a task. Positive [minutesBefore] is *before* [reference].
*
* Synced as a `VALARM` (see `AlarmCodec`). [uid] is the alarm's identity on the
* wire — `X-WR-ALARMUID` / RFC 9074 `UID` — minted when the row is created, so
* every upload of the same alarm names it the same way. [raw] is the `VALARM`
* this row was read from, re-emitted verbatim while the row still means what
* it did, so another client's `ACKNOWLEDGED`, `X-*` and trigger form survive.
*/
@Entity(
tableName = "task_alarms",
foreignKeys = [
@@ -224,4 +244,6 @@ data class TaskAlarmEntity(
@ColumnInfo(name = "minutes_before") val minutesBefore: Int,
@ColumnInfo(name = "reference", defaultValue = "DUE") val reference: AlarmReference = AlarmReference.DUE,
@ColumnInfo(name = "message") val message: String? = null,
@ColumnInfo(name = "uid", defaultValue = "") val uid: String = UUID.randomUUID().toString(),
@ColumnInfo(name = "raw") val raw: String? = null,
)
@@ -2,8 +2,8 @@ package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.InvalidationTracker
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
import de.jeanlucmakiola.agendula.data.tasks.editable
import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.domain.TaskReminderOrder
import de.jeanlucmakiola.agendula.data.tasks.TaskWriteFailedException
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import de.jeanlucmakiola.agendula.domain.Task
@@ -31,7 +31,54 @@ 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")
/** Distinct alarm texts whose `ACKNOWLEDGED` is remembered between scheduler passes. */
private const val ACK_CACHE_SIZE = 512
/**
* [TasksDataSource] over Agendula's own Room store.
@@ -101,19 +148,32 @@ 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)
override fun reminders(): Map<Long, List<TaskReminder>> =
alarms.all().groupBy({ it.taskId }) {
TaskReminder(
minutesBefore = it.minutesBefore,
fromStart = it.reference == AlarmReference.START,
)
}
override fun reminders(): Map<Long, List<TaskReminder>> {
val stored = alarms.all().groupBy({ it.taskId }) { it.asReminder().copy(acknowledgedAt = acknowledged(it)) }
// Turned off elsewhere: present and empty, so nothing falls back to a default.
return stored + alarms.remindersOff().filterNot(stored::containsKey).associateWith { emptyList() }
}
override fun alarms(): Map<Long, TaskReminder> =
reminders().mapNotNull { (id, list) -> list.editable()?.let { id to it } }.toMap()
/** `raw` → its `ACKNOWLEDGED`, so a scheduler pass does not re-parse every alarm. */
private val acknowledgements = object : LinkedHashMap<String, Instant?>(64, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, Instant?>) = size > ACK_CACHE_SIZE
}
private fun acknowledged(row: TaskAlarmEntity): Instant? {
val raw = row.raw ?: return null
return synchronized(acknowledgements) {
if (raw in acknowledgements) acknowledgements[raw] else row.acknowledgedAt().also { acknowledgements[raw] = it }
}
}
/**
* Every occurrence of [row] inside the expansion window, with any
@@ -123,70 +183,69 @@ 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 {
override fun insertTask(form: TaskForm, reminders: List<TaskReminder>?): Long {
if (lists.exists(form.listId) == 0) throw TaskWriteFailedException("insert task: no list ${form.listId}")
val entity = TaskFormWriter.newTask(form, uid = UUID.randomUUID().toString(), now = clock.now(), tzId = zone())
return tasks.insert(TaskFormWriter.withRule(entity, form.rrule)).also { writes.onWritten(setOf(form.listId)) }
var id = 0L
tx {
id = tasks.insert(TaskFormWriter.withRule(entity, form.rrule))
reminders?.let { alarms.syncForTask(id, it) }
writes.onWritten(setOf(form.listId))
}
return id
}
override fun updateTask(taskId: Long, form: TaskForm) {
/**
* [reminders] as [taskId]'s own, inside the caller's transaction.
*
* ⚠️ Part of the task write rather than a second call after it. Once
* reminders sync, a sync landing between the two would upload the task
* without them, or with the ones it is about to lose.
*/
private fun writeReminders(taskId: Long, reminders: List<TaskReminder>?) {
if (reminders == null) return
alarms.syncForTask(taskId, reminders)
}
private fun storedReminders(taskId: Long): List<TaskReminder> =
alarms.forTask(taskId).map { it.asReminder() }.sortedWith(TaskReminderOrder)
override fun updateTask(taskId: Long, form: TaskForm, reminders: List<TaskReminder>?) = tx {
val current = tasks.entity(taskId) ?: throw TaskWriteFailedException("update task $taskId")
val edited = TaskFormWriter.apply(current, form, clock.now(), zone())
writeReminders(taskId, reminders)
if (current.masterId == null && edited.listId != current.listId) {
if (lists.exists(edited.listId) == 0) throw TaskWriteFailedException("move task: no list ${edited.listId}")
database.runInTransaction { move(current, TaskFormWriter.withRule(edited, form.rrule)) }
move(current, TaskFormWriter.withRule(edited, form.rrule))
writes.onWritten(setOf(current.listId, edited.listId))
return
return@tx
}
// ⚠️ An override's list and parent are the master's, the rule
// [updateInstance] states and this path has to keep. An overridden
@@ -220,8 +279,23 @@ class RoomTasksDataSource @Inject constructor(
plan.tombstone?.let { tasks.insert(it) }
}
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) = tx {
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) = tx {
val master = seriesMaster(seriesId)
// ⚠️ The overrides too, where they still carry the series' reminders.
// A fork copies them as a snapshot — completing one occurrence is
// enough — and each override uploads its own VALARMs, so leaving them
// would keep ringing those occurrences at the old lead everywhere. One
// whose reminders were set for that occurrence alone keeps them.
if (reminders != null) {
val before = storedReminders(master.id)
tasks.overrides(master.id)
.filter { storedReminders(it.id) == before }
.forEach {
writeReminders(it.id, reminders)
if (form.remindersChosen && reminders.isEmpty()) alarms.setRemindersOff(it.id, true)
}
}
writeReminders(master.id, reminders)
val (start, due) = resolvedTimes(master, occurrenceStart)
val edited = TaskFormWriter.seriesEdit(master, start, due, form, clock.now(), zone())
// A moved or re-ruled series no longer generates the anchors its
@@ -237,14 +311,14 @@ class RoomTasksDataSource @Inject constructor(
writes.onWritten(setOf(master.listId, edited.listId))
}
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm): Long {
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?): Long {
var created = seriesId
tx {
val master = seriesMaster(seriesId)
val spec = master.recurrenceSpec()
val anchor = master.recurrenceAnchor
if (spec == null || anchor == null || occurrenceStart <= anchor) {
updateSeries(seriesId, occurrenceStart, form)
updateSeries(seriesId, occurrenceStart, form, reminders)
return@tx
}
val now = clock.now()
@@ -287,7 +361,9 @@ class RoomTasksDataSource @Inject constructor(
else TaskFormWriter.shiftValues(laterExceptions, delta, master.timezone ?: zone())
val fresh = TaskFormWriter.withRule(applied, rule).let { if (it.rrule != null) it.copy(exdate = exceptions) else it }
val newId = tasks.insert(fresh)
alarms.forTask(master.id).forEach { alarms.insert(it.copy(id = 0, taskId = newId)) }
// A new UID, so a new resource: the alarms are copies, not the same ones.
alarms.copyToTask(master.id, newId, sameResource = false)
writeReminders(newId, reminders)
val unmoved = fresh.dtstart == start && fresh.due == due && sameRule
tasks.overrides(master.id)
@@ -322,8 +398,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()))
@@ -352,7 +427,7 @@ class RoomTasksDataSource @Inject constructor(
* which is the model least compatible with sync; the override shares its
* master's UID, which is exactly what makes it an override.
*/
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) {
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) = tx {
val master = tasks.entity(taskId) ?: throw TaskWriteFailedException("update instance $taskId")
val now = clock.now()
val existing = tasks.override(taskId, occurrenceStart)
@@ -361,8 +436,9 @@ class RoomTasksDataSource @Inject constructor(
// master's, whatever the form was carrying.
val edited = TaskFormWriter.apply(existing, form, now, zone())
tasks.update(edited.copy(listId = master.listId, parentId = master.parentId))
writeReminders(existing.id, reminders)
writes.onWritten(setOf(master.listId))
return
return@tx
}
val (start, due) = occurrenceTimes(master, occurrenceStart)
val fork = TaskFormWriter.apply(
@@ -374,28 +450,19 @@ class RoomTasksDataSource @Inject constructor(
// The list and parent come from the master: moving one occurrence between
// lists or parents is not something the override model expresses.
val id = tasks.insert(fork.copy(listId = master.listId, parentId = master.parentId))
alarms.replaceAllForTask(id, alarms.forTask(taskId))
// The occurrence starts out with the series' reminders and then takes
// the form's — the master's own are never touched.
alarms.copyToTask(taskId, id, sameResource = true)
writeReminders(id, reminders)
writes.onWritten(setOf(master.listId))
}
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) {
alarms.replaceEditable(
taskId,
minutesBeforeDue?.let { TaskAlarmEntity(taskId = taskId, minutesBefore = it) },
)
}
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) {
alarms.replaceAllForTask(
taskId,
reminders.map {
TaskAlarmEntity(
taskId = taskId,
minutesBefore = it.minutesBefore,
reference = if (it.fromStart) AlarmReference.START else AlarmReference.DUE,
)
},
)
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) = tx {
if (!alarms.syncForTask(taskId, reminders)) return@tx
// The reminders are part of the VTODO now, so changing them is an edit.
val current = tasks.entity(taskId) ?: return@tx
tasks.update(current.copy(isDirty = true, lastModified = clock.now()))
writes.onWritten(setOf(current.listId))
}
override fun setCancelled(taskId: Long, cancelled: Boolean) {
@@ -447,7 +514,7 @@ class RoomTasksDataSource @Inject constructor(
}
val (start, due) = occurrenceTimes(master, occurrenceStart)
val id = tasks.insert(write(newOverride(master, taskId, occurrenceStart, start, due)))
alarms.replaceAllForTask(id, alarms.forTask(taskId))
alarms.copyToTask(taskId, id, sameResource = true)
writes.onWritten(setOf(master.listId))
}
@@ -515,6 +582,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,11 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.domain.ical.ICalParser
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
import kotlin.time.Instant
/** Reads and writes over `task_alarms`. */
@Dao
@@ -16,37 +21,137 @@ interface TaskAlarmDao {
@Query("SELECT * FROM task_alarms WHERE task_id = :taskId ORDER BY id")
fun forTask(taskId: Long): List<TaskAlarmEntity>
/** The reminders of several tasks in one query — one resource's rows, for an upload. */
@Query("SELECT * FROM task_alarms WHERE task_id IN (:taskIds) ORDER BY id")
fun forTasks(taskIds: List<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
@Query("DELETE FROM task_alarms WHERE task_id = :taskId")
fun deleteForTask(taskId: Long): Int
/** See [TaskEntity.remindersOff]. */
@Query("UPDATE tasks SET reminders_off = :off WHERE id = :taskId")
fun setRemindersOff(taskId: Long, off: Boolean): Int
/** The tasks whose alarms were turned off elsewhere — "none", not "the default". */
@Query("SELECT id FROM tasks WHERE reminders_off = 1")
fun remindersOff(): List<Long>
/** Set the task's only reminder, or clear every one with `null`. */
@Transaction
fun replaceForTask(taskId: Long, alarm: TaskAlarmEntity?) = replaceAllForTask(taskId, listOfNotNull(alarm))
/**
* Replace every reminder on the task. The rows that land are always new ones —
* ids are cleared so alarms lifted off another task (forking an occurrence
* copies the master's) insert instead of colliding.
*/
@Transaction
fun replaceAllForTask(taskId: Long, alarms: List<TaskAlarmEntity>) {
fun replaceForTask(taskId: Long, alarm: TaskAlarmEntity?) {
deleteForTask(taskId)
alarms.forEach { insert(it.copy(id = 0, taskId = taskId)) }
}
/**
* Set the task's editable reminder — its last before-due one — or clear it
* with `null`. Start-relative and additional reminders stay as they are.
*/
@Transaction
fun replaceEditable(taskId: Long, alarm: TaskAlarmEntity?) {
forTask(taskId).lastOrNull { it.reference == AlarmReference.DUE }?.let { delete(it.id) }
alarm?.let { insert(it.copy(id = 0, taskId = taskId)) }
}
/**
* Make the task's reminders [wanted], touching only what differs.
*
* ⚠️ A diff, not a delete-and-reinsert. A row that is still wanted keeps its
* [TaskAlarmEntity.uid] and its [TaskAlarmEntity.raw] — which [TaskReminder]
* carries neither of, so a rewrite from it would give every save a new
* alarm identity on the server and throw away another client's `VALARM` the
* user never touched.
*
* Duplicates count: two identical leads are two rows.
*
* @return whether anything changed.
*/
@Transaction
fun syncForTask(taskId: Long, wanted: List<TaskReminder>): Boolean {
val unmatched = wanted.map { it.lead }.toMutableList()
var changed = false
forTask(taskId).forEach { row ->
if (!unmatched.remove(row.asReminder())) {
delete(row.id)
changed = true
}
}
unmatched.forEach {
insert(it.toAlarmEntity(taskId))
changed = true
}
// A reminder of its own again, so the "turned off elsewhere" no longer holds.
if (wanted.isNotEmpty()) setRemindersOff(taskId, false)
return changed
}
/**
* Replace the task's reminders with what a download carried.
*
* Incoming alarms are matched to the rows already here by uid first, then by
* lead, so a reminder the server merely re-described keeps its row rather
* than being churned into a new one.
*
* A download that takes away every alarm the task had marks it
* [TaskEntity.remindersOff]; one that carries alarms clears that.
*/
@Transaction
fun replaceFromServer(taskId: Long, incoming: List<TaskAlarmEntity>) {
val existing = forTask(taskId).toMutableList()
val hadAny = existing.isNotEmpty()
val placed = incoming.map { alarm ->
val match = existing.firstOrNull { it.uid == alarm.uid }
?: existing.firstOrNull { it.asReminder() == alarm.asReminder() }
match?.let(existing::remove)
alarm.copy(id = match?.id ?: 0, taskId = taskId)
}
existing.forEach { delete(it.id) }
placed.forEach { if (it.id == 0L) insert(it) else update(it) }
when {
incoming.isNotEmpty() -> setRemindersOff(taskId, false)
hadAny -> setRemindersOff(taskId, true)
}
}
/**
* Copy [fromTaskId]'s reminders onto [toTaskId].
*
* [sameResource] is for an override forked inside the same series — the
* same `VTODO` UID, so the alarms keep their identity. Anything else is a
* different task on the server and gets fresh uids: two tasks naming one
* alarm is an identity no other client expects.
*/
@Transaction
fun copyToTask(fromTaskId: Long, toTaskId: Long, sameResource: Boolean) {
forTask(fromTaskId).forEach { insert(if (sameResource) it.copy(id = 0, taskId = toTaskId) else it.freshCopy(toTaskId)) }
}
}
/** What the row means to the scheduler and the editor, without its identity. */
fun TaskAlarmEntity.asReminder() = TaskReminder(minutesBefore, reference == AlarmReference.START)
/** The inverse of [asReminder]: a new row, with an identity of its own. */
fun TaskReminder.toAlarmEntity(taskId: Long) = TaskAlarmEntity(
taskId = taskId,
minutesBefore = minutesBefore,
reference = if (fromStart) AlarmReference.START else AlarmReference.DUE,
)
/** The same reminder on another task: what it means, never who it is on the server. */
fun TaskAlarmEntity.freshCopy(taskId: Long) =
TaskAlarmEntity(taskId = taskId, minutesBefore = minutesBefore, reference = reference, message = message)
/**
* The `ACKNOWLEDGED` stamp of the `VALARM` the row was read from, if any.
*
* Read off [TaskAlarmEntity.raw] rather than kept in a column: it is the other
* client's bookkeeping, and it changes only when a download replaces the row.
*/
fun TaskAlarmEntity.acknowledgedAt(): Instant? {
val text = raw ?: return null
if (!text.contains("ACKNOWLEDGED", ignoreCase = true)) return null
val property = runCatching { ICalParser.parse(text) }.getOrNull()?.property("ACKNOWLEDGED") ?: return null
return (ICalValues.parseTime(property) as? ICalValues.TimeValue.Timed)?.instant
}
@@ -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>
@@ -63,6 +63,7 @@ object TaskFormWriter {
isAllDay = form.isAllDay,
timezone = if (timed) tzId else null,
parentId = form.parentId?.takeIf { it > 0 },
remindersOff = if (form.remindersChosen) form.reminders.isEmpty() else current.remindersOff,
lastModified = now,
isDirty = true,
)
@@ -5,6 +5,8 @@ import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import java.util.UUID
import kotlin.time.Instant
/**
* Agendula's own task store. Four tables, designed from
@@ -20,7 +22,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
TaskEntity::class,
TaskAlarmEntity::class,
],
version = 2,
version = 3,
exportSchema = true,
)
@TypeConverters(Converters::class)
@@ -63,5 +65,113 @@ abstract class TasksDatabase : RoomDatabase() {
}
}
}
/**
* v3 syncs reminders as `VALARM`s, which takes two things the rows did not
* have: an identity on the wire (`uid`) and the original text (`raw`).
*
* Claimable `VALARM`s are lifted out of the residue for every row, synced
* or not — an unchanged task is never downloaded again, and an imported
* one never was. Not marked dirty: the server already has them. Where a
* task held the same lead in both stores (an override forked before v3
* copies the master's alarms *and* carries its `VALARM`s), the lifted
* one wins: it is the one with an identity.
*
* Reminders that so far lived only on this device are pushed once, by
* marking their tasks dirty — bounded to open tasks in synced lists whose
* reminder can still fire, so the upgrade is not a burst of PUTs landing
* on other clients as long-missed alarms.
*
* `tasks.reminders_off` starts at 0 everywhere: until now nothing could
* have turned a synced task's alarms off.
*/
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE task_alarms ADD COLUMN uid TEXT NOT NULL DEFAULT ''")
db.execSQL("ALTER TABLE task_alarms ADD COLUMN raw TEXT")
db.execSQL("ALTER TABLE tasks ADD COLUMN reminders_off INTEGER NOT NULL DEFAULT 0")
// A version-4 UUID per row, in one statement: `randomblob` is
// evaluated per row, and the upgrade runs on app start.
db.execSQL(
"""
UPDATE task_alarms SET uid =
lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' ||
substr(lower(hex(randomblob(2))), 2) || '-' ||
substr('89ab', 1 + (abs(random()) % 4), 1) || substr(lower(hex(randomblob(2))), 2) || '-' ||
lower(hex(randomblob(6)))
""".trimIndent(),
)
data class Lifted(val taskId: Long, val result: AlarmLift.Result)
val lifted = mutableListOf<Lifted>()
db.query(
"SELECT id, dtstart, due, is_all_day, rrule, rdate, unknown_properties FROM tasks " +
"WHERE unknown_properties LIKE '%VALARM%'",
).use { c ->
while (c.moveToNext()) {
val task = AlarmLift.Task(
dtstart = if (c.isNull(1)) null else Instant.fromEpochMilliseconds(c.getLong(1)),
due = if (c.isNull(2)) null else Instant.fromEpochMilliseconds(c.getLong(2)),
isAllDay = c.getInt(3) != 0,
recurring = !c.isNull(4) || !c.isNull(5),
)
val result = AlarmLift.lift(c.getString(6), task)
if (result.alarms.isNotEmpty()) lifted += Lifted(c.getLong(0), result)
}
}
lifted.forEach { (taskId, result) ->
val local = mutableListOf<Triple<Long, Int, String>>()
db.query(
"SELECT id, minutes_before, reference FROM task_alarms WHERE task_id = ?",
arrayOf<Any?>(taskId),
).use { c -> while (c.moveToNext()) local += Triple(c.getLong(0), c.getInt(1), c.getString(2)) }
result.alarms.forEach { alarm ->
local.firstOrNull { it.second == alarm.minutesBefore && it.third == alarm.reference.name }
?.let { duplicate ->
local.remove(duplicate)
db.execSQL("DELETE FROM task_alarms WHERE id = ?", arrayOf<Any?>(duplicate.first))
}
db.execSQL(
"INSERT INTO task_alarms (task_id, minutes_before, reference, uid, raw) VALUES (?, ?, ?, ?, ?)",
arrayOf<Any?>(
taskId,
alarm.minutesBefore,
alarm.reference.name,
alarm.uid ?: UUID.randomUUID().toString(),
alarm.raw,
),
)
}
db.execSQL("UPDATE tasks SET unknown_properties = ? WHERE id = ?", arrayOf<Any?>(result.residue, taskId))
}
// ⚠️ `raw IS NULL` is "never on the server": everything lifted
// above carries its original. Status 0/1 are NEEDS-ACTION and
// IN-PROCESS (`ICalStatus`).
//
// ⚠️ Never a list the upload cannot write to. A dirty row is
// also one the download will not overwrite, so marking a task in
// a read-only share — or a confidential one, which the upload
// skips in a shared collection — would freeze it for good: never
// sent, and the owner's edits never taken. Classification 2 is
// CONFIDENTIAL.
//
// LAST-MODIFIED moves with it: the body changes, and a client
// reconciling by that stamp would otherwise keep its stale copy.
val now = System.currentTimeMillis()
db.execSQL(
"""
UPDATE tasks SET is_dirty = 1, last_modified = ?
WHERE is_deleted = 0
AND status IN (0, 1)
AND (classification IS NULL OR classification != 2)
AND list_id IN (SELECT id FROM task_lists WHERE account_id IS NOT NULL AND is_read_only = 0)
AND (rrule IS NOT NULL OR rdate IS NOT NULL OR due > ?)
AND id IN (SELECT task_id FROM task_alarms WHERE raw IS NULL)
""".trimIndent(),
arrayOf<Any?>(now, now),
)
}
}
}
}
@@ -7,10 +7,9 @@ import androidx.datastore.preferences.core.edit
import de.jeanlucmakiola.agendula.data.di.ExternalStore
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
import de.jeanlucmakiola.agendula.data.tasks.room.toAlarmEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
@@ -200,7 +199,7 @@ class ExternalImport @Inject constructor(
var alarmCount = 0
for ((sourceTaskId, reminders) in snapshot.alarms) {
val taskId = taskIds[sourceTaskId] ?: continue
reminders.forEach { alarmDao.insert(it.toEntity(taskId)) }
reminders.forEach { alarmDao.insert(it.toAlarmEntity(taskId)) }
alarmCount += reminders.size
}
@@ -228,12 +227,6 @@ class ExternalImport @Inject constructor(
lastModified = lastModified,
)
private fun TaskReminder.toEntity(taskId: Long) = TaskAlarmEntity(
taskId = taskId,
minutesBefore = minutesBefore,
reference = if (fromStart) AlarmReference.START else AlarmReference.DUE,
)
private fun verify(written: TransferCounts, before: TransferCounts) {
val after = tableCounts()
check(after.lists - before.lists == written.lists) {
@@ -7,7 +7,9 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.tasks.ical.CalendarResource
import de.jeanlucmakiola.agendula.data.tasks.ical.VTodoMapper
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.data.tasks.room.freshCopy
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import kotlinx.coroutines.CoroutineDispatcher
@@ -65,7 +67,11 @@ interface IcsImportStore {
fun <T> transaction(block: () -> T): T
fun createList(name: String, color: Int): Long
fun hasUid(listId: Long, uid: String): Boolean
/** Whether any list holds [uid] — the same file imported somewhere else before. */
fun hasUidAnywhere(uid: String): Boolean
fun insert(row: TaskEntity): Long
fun insertAlarms(taskId: Long, alarms: List<TaskAlarmEntity>)
fun masterByUid(listId: Long, uid: String): TaskEntity?
fun setParent(taskId: Long, parentId: Long?)
}
@@ -76,9 +82,9 @@ interface IcsImportStore {
*
* Goes through the same [CalendarResource] / [VTodoMapper] path CalDAV sync
* uses, so recurrence, `RECURRENCE-ID` overrides, `RELATED-TO` parents and every
* property the mapper does not claim (`VALARM`s included) survive as they would
* on a download. `VALARM`s stay in the residue: like sync, the import does not
* turn them into local reminders.
* property the mapper does not claim survive as they would on a download. A
* `VALARM` the mapper claims becomes a reminder on the task, exactly as a
* download does; the rest stay in the residue.
*
* Tasks whose UID the target list already holds are skipped, so importing the
* same file twice is harmless.
@@ -200,8 +206,17 @@ class IcsImport @Inject constructor(
skipped++
continue
}
// ⚠️ The same file in a second list is a second task carrying
// the first one's alarm identities. Those are what clients
// track a dismissal by, so the copy's alarms are new ones.
val copied = store.hasUidAnywhere(task.uid)
fun alarmsOf(mapped: VTodoMapper.Mapped) =
if (copied) mapped.alarms.map { it.freshCopy(0) } else mapped.alarms
val masterId = store.insert(task.master.entity.forImport(listId, masterId = null))
task.overrides.forEach { store.insert(it.entity.forImport(listId, masterId)) }
store.insertAlarms(masterId, alarmsOf(task.master))
task.overrides.forEach {
store.insertAlarms(store.insert(it.entity.forImport(listId, masterId)), alarmsOf(it))
}
task.master.parentUid?.let { parents += masterId to it }
imported++
}
@@ -270,8 +285,15 @@ class RoomIcsImportStore(private val database: TasksDatabase) : IcsImportStore {
arrayOf<Any?>(listId, uid),
).use { it.moveToFirst() }
override fun hasUidAnywhere(uid: String): Boolean =
database.query("SELECT 1 FROM tasks WHERE uid = ? LIMIT 1", arrayOf<Any?>(uid)).use { it.moveToFirst() }
override fun insert(row: TaskEntity): Long = database.tasks().insert(row)
override fun insertAlarms(taskId: Long, alarms: List<TaskAlarmEntity>) {
alarms.forEach { database.alarms().insert(it.copy(id = 0, taskId = taskId)) }
}
override fun masterByUid(listId: Long, uid: String): TaskEntity? = database.tasks().byUid(listId, uid)
override fun setParent(taskId: Long, parentId: Long?) {
@@ -18,8 +18,10 @@ data class TaskForm(
val parentId: Long? = null,
/** 0..100 progress; `null` leaves it unset (cleared). */
val percentComplete: Int? = null,
/** The task's own reminders, in minutes before [due]; empty = the list's or app default applies. */
val reminders: List<Int> = emptyList(),
/** The task's own reminders; empty = the list's or app default applies, unless [remindersChosen]. */
val reminders: List<TaskReminder> = emptyList(),
/** Whether [reminders] are exactly the task's, as the editor showed them: empty is then none. */
val remindersChosen: Boolean = false,
val location: String? = null,
val url: String? = null,
val tags: List<String> = emptyList(),
@@ -30,14 +32,14 @@ data class TaskForm(
if (title.isBlank()) add(TaskFormError.BLANK_TITLE)
if (listId <= 0L) add(TaskFormError.NO_LIST)
if (start != null && due != null && due < start) add(TaskFormError.DUE_BEFORE_START)
if (reminders.isNotEmpty() && due == null) add(TaskFormError.REMINDER_WITHOUT_DUE)
if (reminders.isNotEmpty() && start == null && due == null) add(TaskFormError.REMINDER_WITHOUT_DATE)
if (rrule != null && start == null && due == null) add(TaskFormError.RECURRENCE_WITHOUT_DATE)
}
val isValid: Boolean get() = validate().isEmpty()
}
enum class TaskFormError { BLANK_TITLE, NO_LIST, DUE_BEFORE_START, REMINDER_WITHOUT_DUE, RECURRENCE_WITHOUT_DATE }
enum class TaskFormError { BLANK_TITLE, NO_LIST, DUE_BEFORE_START, REMINDER_WITHOUT_DATE, RECURRENCE_WITHOUT_DATE }
/**
* Optional edit-form sections that can be shown or tucked behind "More fields"
@@ -0,0 +1,85 @@
package de.jeanlucmakiola.agendula.domain
import de.jeanlucmakiola.floret.reminders.ReminderLead
import kotlin.time.Instant
const val DAY_MINUTES = 24 * 60
/**
* The earliest lead an all-day reminder can have: 00:01 on the day. On an
* all-day task a lead that is not whole days counts back from the date's
* midnight, so `-540` is 09:00 on the day — see [TaskReminder.allDay].
*/
const val MIN_ALL_DAY_LEAD = -(DAY_MINUTES - 1)
/** A stored reminder: how long before the start ([fromStart]) or the due date. */
data class TaskReminder(
val minutesBefore: Int,
val fromStart: Boolean = false,
/**
* When another client last dismissed this alarm (RFC 9074 `ACKNOWLEDGED`):
* a trigger at or before it has been dealt with and is not fired here.
* Never part of what a reminder *is* — see [lead].
*/
val acknowledgedAt: Instant? = null,
) {
/** The reminder without its bookkeeping, for comparing two of them. */
val lead: TaskReminder get() = if (acknowledgedAt == null) this else copy(acknowledgedAt = null)
/** This reminder read as an all-day one. */
val allDay: AllDayReminder get() = AllDayReminder.of(minutesBefore)
/** This reminder's lead rounded to the nearest whole day, as the earlier all-day leads were. */
fun inWholeDays(): TaskReminder =
copy(minutesBefore = (minutesBefore.coerceAtLeast(0) + DAY_MINUTES / 2) / DAY_MINUTES * DAY_MINUTES)
companion object {
/**
* The all-day reminder [daysBefore] the date at [minuteOfDay], or at the
* device's all-day time when that is null. Midnight would read back as
* the latter, so it is 00:01 — see [allDayMinuteOfDay].
*/
fun allDay(daysBefore: Int, minuteOfDay: Int?, fromStart: Boolean = false) =
TaskReminder(daysBefore * DAY_MINUTES - (minuteOfDay?.let(::allDayMinuteOfDay) ?: 0), fromStart)
/** The time an all-day reminder can name: any but midnight, which is a whole-day lead. */
fun allDayMinuteOfDay(minuteOfDay: Int): Int = minuteOfDay.coerceAtLeast(1)
}
}
/** An all-day reminder as the day it rings on and, when it names one, its time. */
data class AllDayReminder(val daysBefore: Int, val minuteOfDay: Int?) {
companion object {
/** [minutesBefore] on an all-day task; anything after the day is clamped to its last minute. */
fun of(minutesBefore: Int): AllDayReminder {
val minutes = minutesBefore.coerceAtLeast(MIN_ALL_DAY_LEAD)
if (minutes % DAY_MINUTES == 0) return AllDayReminder(minutes / DAY_MINUTES, minuteOfDay = null)
val days = Math.floorDiv(minutes, DAY_MINUTES) + 1
return AllDayReminder(days, minuteOfDay = days * DAY_MINUTES - minutes)
}
}
}
/** A canonical order, for comparing two sets of reminders. */
val TaskReminderOrder: Comparator<TaskReminder> =
compareBy<TaskReminder>({ !it.fromStart }, { -it.minutesBefore })
/**
* The order the editor and the detail screen list reminders in: start's first,
* then earliest first. An all-day reminder without a time rings at [allDayMinuteOfDay].
*/
fun taskReminderDisplayOrder(allDay: Boolean, allDayMinuteOfDay: Int): Comparator<TaskReminder> =
compareBy<TaskReminder>({ !it.fromStart }, { reminder ->
if (!allDay) {
-reminder.minutesBefore
} else {
val (days, minuteOfDay) = reminder.allDay
(minuteOfDay ?: allDayMinuteOfDay) - days * DAY_MINUTES
}
})
/** A default reminder's lead as a reminder of the task's own. */
fun ReminderLead.asTaskReminder(): TaskReminder = TaskReminder(minutes, fromStart)
/** This reminder's lead, as default reminders are kept. */
fun TaskReminder.asReminderLead(): ReminderLead = ReminderLead(minutesBefore, fromStart)
@@ -161,6 +161,57 @@ object ICalValues {
}
}
private val DURATION = Regex(
"([+-])?P(?:(\\d+)W|(?:(\\d+)D)?(?:T(?:(\\d+)H)?(?:(\\d+)M)?(?:(\\d+)S)?)?)",
RegexOption.IGNORE_CASE,
)
/**
* RFC 5545 §3.3.6 `dur-value` in signed seconds: `-PT15M` → -900, `P1W` →
* 604800, `-P1DT2H` → -93600. Null when it does not parse.
*
* A little more lenient than the grammar — `PT1H5S` skips the minutes the
* grammar would require — because a stricter reader would leave another
* client's alarm unread over a form nobody misreads.
*/
fun parseDuration(value: String): Long? {
val match = DURATION.matchEntire(value.trim()) ?: return null
val (sign, weeks, days, hours, minutes, seconds) = match.destructured
// "P" and "PT" alone match the pattern and mean nothing.
if (listOf(weeks, days, hours, minutes, seconds).all(String::isEmpty)) return null
if (value.trim().endsWith("T", ignoreCase = true)) return null
fun part(text: String, scale: Long): Long =
if (text.isEmpty()) 0L else Math.multiplyExact(text.toLong(), scale)
val total = runCatching {
listOf(
part(weeks, 7 * 86_400L), part(days, 86_400L),
part(hours, 3_600L), part(minutes, 60L), part(seconds, 1L),
).reduce(Math::addExact)
}.getOrNull() ?: return null
return if (sign == "-") -total else total
}
/** The inverse of [parseDuration], in days and time, never weeks: `-PT15M`, `-P1D`, `PT0S`. */
fun formatDuration(seconds: Long): String {
if (seconds == 0L) return "PT0S"
val magnitude = kotlin.math.abs(seconds)
val days = magnitude / 86_400
val hours = magnitude % 86_400 / 3_600
val minutes = magnitude % 3_600 / 60
val rest = magnitude % 60
return buildString {
if (seconds < 0) append('-')
append('P')
if (days > 0) append(days).append('D')
if (hours > 0 || minutes > 0 || rest > 0) {
append('T')
if (hours > 0) append(hours).append('H')
if (minutes > 0) append(minutes).append('M')
if (rest > 0) append(rest).append('S')
}
}
}
private fun java.time.Instant.toKotlin(): Instant =
Instant.fromEpochMilliseconds(toEpochMilli())
}
@@ -5,6 +5,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.domain.DAY_MINUTES
import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.floret.reminders.ReminderUnit
/** Common reminder lead times offered as quick picks in the reminder pickers. */
@@ -33,9 +35,57 @@ fun reminderLeadTimeLabel(minutes: Int, allDay: Boolean = false): String = when
minutes <= 0 -> stringResource(R.string.reminder_at_due)
minutes % 10_080 == 0 ->
pluralStringResource(R.plurals.reminder_weeks, minutes / 10_080, minutes / 10_080)
minutes % 1_440 == 0 ->
pluralStringResource(R.plurals.reminder_days, minutes / 1_440, minutes / 1_440)
minutes % DAY_MINUTES == 0 ->
pluralStringResource(R.plurals.reminder_days, minutes / DAY_MINUTES, minutes / DAY_MINUTES)
minutes % 60 == 0 ->
pluralStringResource(R.plurals.reminder_hours, minutes / 60, minutes / 60)
else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes)
}
/**
* [reminderLeadTimeLabel] naming the date the lead counts from, for a task
* that has both: "15 minutes before the start", "On the due date".
*/
@Composable
fun anchoredLeadTimeLabel(minutes: Int, allDay: Boolean, fromStart: Boolean): String {
@Composable
fun plural(start: Int, due: Int, count: Int) =
pluralStringResource(if (fromStart) start else due, count, count)
return when {
minutes <= 0 && allDay ->
stringResource(if (fromStart) R.string.reminder_on_start_date else R.string.reminder_on_due_date)
minutes <= 0 -> stringResource(if (fromStart) R.string.reminder_at_start else R.string.reminder_at_due_time)
minutes % 10_080 == 0 ->
plural(R.plurals.reminder_weeks_before_start, R.plurals.reminder_weeks_before_due, minutes / 10_080)
minutes % DAY_MINUTES == 0 ->
plural(R.plurals.reminder_days_before_start, R.plurals.reminder_days_before_due, minutes / DAY_MINUTES)
minutes % 60 == 0 ->
plural(R.plurals.reminder_hours_before_start, R.plurals.reminder_hours_before_due, minutes / 60)
else -> plural(R.plurals.reminder_minutes_before_start, R.plurals.reminder_minutes_before_due, minutes)
}
}
/** A lead's label, naming its date when [anchored]. */
@Composable
fun reminderLabel(minutes: Int, allDay: Boolean, fromStart: Boolean, anchored: Boolean): String =
if (anchored) anchoredLeadTimeLabel(minutes, allDay, fromStart) else reminderLeadTimeLabel(minutes, allDay)
/**
* One of a task's own reminders in one line, naming its date when [anchored].
* An all-day one names its day and the time it rings at — its own, or
* [allDayMinuteOfDay] when it has none.
*/
@Composable
fun taskReminderLabel(reminder: TaskReminder, allDay: Boolean, allDayMinuteOfDay: Int, anchored: Boolean): String {
if (!allDay) return reminderLabel(reminder.minutesBefore, false, reminder.fromStart, anchored)
val (days, minuteOfDay) = reminder.allDay
return stringResource(
R.string.reminder_at_time,
reminderLabel(days * DAY_MINUTES, true, reminder.fromStart, anchored),
formatMinuteOfDay(minuteOfDay ?: allDayMinuteOfDay, LocalUse24HourFormat.current),
)
}
@StringRes
fun reminderAnchorLabel(fromStart: Boolean): Int =
if (fromStart) R.string.reminder_anchor_start else R.string.reminder_anchor_due
@@ -1,12 +1,13 @@
package de.jeanlucmakiola.agendula.ui.common
import de.jeanlucmakiola.floret.components.CustomAmountEditor
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.components.DialogAmountField
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.reminders.reminderOverrideForMinutes
import de.jeanlucmakiola.floret.reminders.ReminderLead
import de.jeanlucmakiola.floret.reminders.reminderOverrideForLeads
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
@@ -16,27 +17,19 @@ import de.jeanlucmakiola.floret.reminders.decomposeReminderMinutes
import androidx.compose.animation.AnimatedVisibility
import de.jeanlucmakiola.floret.identity.collapseExit
import de.jeanlucmakiola.floret.identity.expandEnter
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@@ -46,8 +39,10 @@ import de.jeanlucmakiola.agendula.R
* Reminder lead-time picker, full-screen: the grouped list of preset lead times
* plus a "Custom" row that expands an inline amount field + unit selector.
* [allowInherit] adds a "Use default" row (per-list overrides); [allowNone] adds
* a "None" row; [allDay] switches to day-scale presets and wording. Returns the
* choice as a [ReminderOverride]. Ported from Calendula's ReminderDefaultPicker.
* a "None" row; [allDay] switches to day-scale presets and wording, and its
* custom amounts to days and weeks. [header] sits above the rows, for whatever
* else the caller's reminder carries. Returns the choice as a
* [ReminderOverride]. Ported from Calendula's ReminderDefaultPicker.
*/
@Composable
fun ReminderLeadPicker(
@@ -59,6 +54,7 @@ fun ReminderLeadPicker(
onDismiss: () -> Unit,
allDay: Boolean = false,
presets: List<Int> = if (allDay) ALLDAY_REMINDER_PRESETS else REMINDER_PRESETS,
header: (@Composable ColumnScope.() -> Unit)? = null,
) {
// Agendula is single-reminder: an override carries a one-element list, so
// take the single value for this single-select picker.
@@ -68,7 +64,7 @@ fun ReminderLeadPicker(
var customExpanded by rememberSaveable { mutableStateOf(false) }
var amountText by rememberSaveable { mutableStateOf(seed.amount?.toString() ?: "") }
var unit by rememberSaveable { mutableStateOf(seed.unit) }
var unit by rememberSaveable { mutableStateOf(seed.unit.takeIf { it in reminderUnits(allDay) } ?: reminderUnits(allDay).first()) }
val options = buildList {
if (allowInherit) add(ReminderOverride.Inherit)
@@ -78,6 +74,7 @@ fun ReminderLeadPicker(
val rowCount = options.size + 1 // + the custom row
FullScreenPicker(title = title, onDismiss = onDismiss) {
header?.invoke(this)
options.forEachIndexed { index, option ->
val isSelected = option == selected
GroupedRow(
@@ -112,6 +109,8 @@ fun ReminderLeadPicker(
onAmountChange = { amountText = it },
unit = unit,
onUnitChange = { unit = it },
units = reminderUnits(allDay),
allDay = allDay,
onConfirm = { minutes ->
onSelect(ReminderOverride.Minutes(listOf(minutes)))
onDismiss()
@@ -129,7 +128,9 @@ fun ReminderLeadPicker(
* Clearing the last time falls back to "Use default" on a per-list picker and to
* "No reminder" on the global one. Changes apply live; the user leaves via back.
* [allDay] switches to day-scale presets, and [leadTimeSummary] adds a second
* line to each lead-time row. Ported from Calendula's ReminderDefaultPicker.
* line to each lead-time row. A start / due toggle picks which date the rows
* count from, and the choice carries it ([ReminderOverride.Minutes.fromStart]).
* Ported from Calendula's ReminderDefaultPicker.
*/
@Composable
fun ReminderDefaultPicker(
@@ -149,24 +150,27 @@ fun ReminderDefaultPicker(
LaunchedEffect(selected) { if (!userEdited) current = selected }
val inherits = current is ReminderOverride.Inherit
val isNone = current is ReminderOverride.None
val selectedMinutes = (current as? ReminderOverride.Minutes)?.minutes.orEmpty()
val selectedLeads = (current as? ReminderOverride.Minutes)?.leads.orEmpty()
var fromStart by rememberSaveable { mutableStateOf(true) }
val selectedMinutes = selectedLeads.filter { it.fromStart == fromStart }.map { it.minutes }
// Custom values seen this session keep their row once unchecked.
val seenCustom = remember { mutableSetOf<Int>() }
seenCustom += selectedMinutes.filter { it !in presets }
val rows = presets + seenCustom.sorted()
val seenCustom = remember { mutableSetOf<ReminderLead>() }
seenCustom += selectedLeads.filter { it.minutes !in presets }
val rows = presets + seenCustom.filter { it.fromStart == fromStart }.map { it.minutes }.sorted()
var customExpanded by rememberSaveable { mutableStateOf(false) }
var amountText by rememberSaveable { mutableStateOf("") }
var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) }
var unit by rememberSaveable { mutableStateOf(reminderUnits(allDay).first()) }
fun apply(override: ReminderOverride) {
userEdited = true
current = override
onSelect(override)
}
fun emit(minutes: List<Int>) = apply(reminderOverrideForMinutes(minutes, allowInherit))
fun emit(leads: List<ReminderLead>) = apply(reminderOverrideForLeads(leads, allowInherit))
fun lead(minute: Int) = ReminderLead(minute, fromStart)
fun toggle(minute: Int) =
emit(if (minute in selectedMinutes) selectedMinutes - minute else selectedMinutes + minute)
emit(if (minute in selectedMinutes) selectedLeads - lead(minute) else selectedLeads + lead(minute))
FullScreenPicker(title = title, onDismiss = onDismiss) {
if (allowInherit) {
@@ -186,6 +190,8 @@ fun ReminderDefaultPicker(
onClick = { apply(ReminderOverride.None) },
)
Spacer(Modifier.height(24.dp))
ReminderAnchorToggle(fromStart = fromStart, onChange = { fromStart = it })
Spacer(Modifier.height(16.dp))
val rowCount = rows.size + 1 // + the custom row
rows.forEachIndexed { index, minute ->
val checked = minute in selectedMinutes
@@ -213,8 +219,10 @@ fun ReminderDefaultPicker(
onAmountChange = { amountText = it },
unit = unit,
onUnitChange = { unit = it },
units = reminderUnits(allDay),
allDay = allDay,
onConfirm = { minutes ->
emit(selectedMinutes + minutes)
emit(selectedLeads + lead(minutes))
amountText = ""
customExpanded = false
},
@@ -223,57 +231,54 @@ fun ReminderDefaultPicker(
}
}
/** Which date a reminder counts back from. */
@Composable
fun ReminderAnchorToggle(fromStart: Boolean, onChange: (Boolean) -> Unit) {
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) {
listOf(true, false).forEachIndexed { index, start ->
SegmentedButton(
selected = fromStart == start,
onClick = { onChange(start) },
shape = SegmentedButtonDefaults.itemShape(index, 2),
label = { Text(stringResource(reminderAnchorLabel(start))) },
)
}
}
}
@Composable
private fun CustomReminderEditor(
amountText: String,
onAmountChange: (String) -> Unit,
unit: ReminderUnit,
onUnitChange: (ReminderUnit) -> Unit,
units: List<ReminderUnit>,
allDay: Boolean,
onConfirm: (Int) -> Unit,
) {
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
// A Position.Bottom shape: tight top corners meeting the row, full bottom.
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
ReminderUnit.entries.forEachIndexed { index, entry ->
SegmentedButton(
selected = unit == entry,
onClick = { onUnitChange(entry) },
shape = SegmentedButtonDefaults.itemShape(index, ReminderUnit.entries.size),
label = { Text(stringResource(reminderUnitLabel(entry))) },
)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
DialogAmountField(value = amountText, onValueChange = onAmountChange, placeholder = "10")
Spacer(Modifier.width(16.dp))
Text(
text = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) }
?: stringResource(R.string.reminder_custom_amount),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(16.dp))
FilledTonalButton(
onClick = { amount?.let { onConfirm(it * unit.minutesFactor) } },
enabled = amount != null,
) {
Text(stringResource(R.string.reminder_custom_set))
}
}
}
}
CustomAmountEditor(
amountText = amountText,
onAmountChange = onAmountChange,
preview = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor, allDay) }
?: stringResource(R.string.reminder_custom_amount),
setLabel = stringResource(R.string.reminder_custom_set),
confirmEnabled = amount != null,
onConfirm = { amount?.let { onConfirm(it * unit.minutesFactor) } },
unitLabels = units.map { stringResource(reminderUnitLabel(it)) },
selectedUnit = units.indexOf(unit),
onUnitChange = { onUnitChange(units[it]) },
)
}
/**
* The units a custom lead is entered in. An all-day reminder's time of day is
* set on its own, so its lead is whole days: an hour count would read back as a
* time of day.
*/
private fun reminderUnits(allDay: Boolean): List<ReminderUnit> =
if (allDay) listOf(ReminderUnit.Days, ReminderUnit.Weeks) else ReminderUnit.entries
@Composable
private fun reminderOverrideLabel(override: ReminderOverride, allDay: Boolean): String = when (override) {
ReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
@@ -58,7 +58,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.pluralStringResource
import androidx.core.net.toUri
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope
import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat
@@ -66,7 +66,7 @@ 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
import de.jeanlucmakiola.agendula.ui.common.taskReminderLabel
import de.jeanlucmakiola.floret.locale.currentLocale
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -198,6 +198,7 @@ fun TaskDetailScreen(
detail = s.detail,
inner = inner,
reminders = s.reminders,
allDayMinuteOfDay = s.allDayMinuteOfDay,
onToggle = { viewModel.toggleComplete(s.detail.task) },
onToggleSubtask = { viewModel.toggleComplete(it) },
onAddSubtask = { viewModel.addSubtask(it) },
@@ -251,6 +252,7 @@ private fun DetailBody(
detail: TaskDetail,
inner: androidx.compose.foundation.layout.PaddingValues,
reminders: List<TaskReminder>,
allDayMinuteOfDay: Int,
onToggle: () -> Unit,
onToggleSubtask: (Task) -> Unit,
onAddSubtask: (String) -> Unit,
@@ -352,8 +354,12 @@ private fun DetailBody(
DetailCard(icon = Icons.Rounded.Notifications, iconContentDescription = stringResource(R.string.edit_reminder_label)) {
reminders.forEach { reminder ->
Text(
if (reminder.fromStart) stringResource(R.string.detail_reminder_before_start, reminderLeadTimeLabel(reminder.minutesBefore))
else reminderLeadTimeLabel(reminder.minutesBefore, task.isAllDay),
taskReminderLabel(
reminder,
task.isAllDay,
allDayMinuteOfDay,
anchored = task.start != null && task.due != null,
),
style = MaterialTheme.typography.titleMedium,
)
}
@@ -18,6 +18,11 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import de.jeanlucmakiola.agendula.data.prefs.DEFAULT_ALL_DAY_REMINDER_MINUTE
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.domain.taskReminderDisplayOrder
import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
@@ -30,7 +35,9 @@ sealed interface TaskDetailUiState {
data class Content(
val detail: TaskDetail,
/** The task's own reminders; empty when the list's or app default applies. */
val reminders: List<de.jeanlucmakiola.agendula.data.tasks.TaskReminder> = emptyList(),
val reminders: List<de.jeanlucmakiola.agendula.domain.TaskReminder> = emptyList(),
/** The time an all-day reminder without one of its own rings at. */
val allDayMinuteOfDay: Int = DEFAULT_ALL_DAY_REMINDER_MINUTE,
) : TaskDetailUiState
}
@@ -38,6 +45,7 @@ sealed interface TaskDetailUiState {
@HiltViewModel
class TaskDetailViewModel @Inject constructor(
private val repository: TasksRepository,
private val settingsPrefs: SettingsPrefs,
@ApplicationScope private val appScope: CoroutineScope,
) : ViewModel() {
@@ -46,14 +54,18 @@ class TaskDetailViewModel @Inject constructor(
val state: StateFlow<TaskDetailUiState> =
target.filterNotNull()
.flatMapLatest { (id, occurrence) ->
val allDayMinute = settingsPrefs.settings.map { it.allDayReminderMinuteOfDay }.distinctUntilChanged()
repository.taskDetail(id, occurrence)
.map<TaskDetail?, TaskDetailUiState> { detail ->
if (detail == null) {
.map { detail -> detail?.let { it to repository.remindersFor(it.task.taskId) } }
.combine(allDayMinute) { loaded, minute ->
if (loaded == null) {
TaskDetailUiState.NotFound
} else {
val (detail, reminders) = loaded
TaskDetailUiState.Content(
detail = detail,
reminders = repository.remindersFor(detail.task.taskId),
reminders = reminders.sortedWith(taskReminderDisplayOrder(detail.task.isAllDay, minute)),
allDayMinuteOfDay = minute,
)
}
}
@@ -26,11 +26,14 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import de.jeanlucmakiola.agendula.ui.common.RecurringScopeDialog
import de.jeanlucmakiola.agendula.ui.common.ReminderAnchorToggle
import de.jeanlucmakiola.agendula.ui.common.ReminderLeadPicker
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.agendula.ui.common.TimePickerAlert
import de.jeanlucmakiola.agendula.ui.common.formatMinuteOfDay
import de.jeanlucmakiola.agendula.ui.common.taskReminderLabel
import de.jeanlucmakiola.floret.components.revealOnFocus
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedListInset
@@ -123,6 +126,8 @@ import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFormError
import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.domain.DAY_MINUTES
import de.jeanlucmakiola.agendula.domain.TaskSection
import de.jeanlucmakiola.agendula.domain.TaskSections
import de.jeanlucmakiola.floret.components.GroupedRow
@@ -263,6 +268,8 @@ private fun EditContent(
// the title takes the target list's colour.
val accent = selectedList?.let { pastelize(it.color, dark) } ?: MaterialTheme.colorScheme.primary
val gap = 12.dp
// A reminder's anchor only says something when the task has both to choose from.
val bothDates = state.start != null && state.due != null
var pickerTarget by rememberSaveable { mutableStateOf<PickerTarget?>(null) }
var showListPicker by rememberSaveable { mutableStateOf(false) }
@@ -559,23 +566,25 @@ private fun EditContent(
Spacer(Modifier.height(gap))
// One card per reminder, connected, the last row adding another.
val rows = state.reminders.size + 1
state.reminders.forEachIndexed { index, minutes ->
state.reminders.forEachIndexed { index, reminder ->
ReminderItemCard(
position = positionOf(index, rows),
title = reminderLeadTimeLabel(minutes, state.isAllDay),
onRemove = { viewModel.removeReminder(minutes) },
title = taskReminderLabel(reminder, state.isAllDay, state.allDayMinuteOfDay, anchored = bothDates),
onRemove = { viewModel.removeReminder(reminder) },
)
}
AddReminderCard(
position = positionOf(state.reminders.size, rows),
label = stringResource(
if (state.reminders.isEmpty()) R.string.edit_reminder_add_first else R.string.edit_reminder_add,
// Only a set the user has not chosen still falls back to the default.
if (state.reminders.isEmpty() && !state.remindersChosen) R.string.edit_reminder_add_first
else R.string.edit_reminder_add,
),
onClick = { showReminderPicker = true },
)
if (TaskFormError.REMINDER_WITHOUT_DUE in state.errors) {
if (TaskFormError.REMINDER_WITHOUT_DATE in state.errors) {
Spacer(Modifier.height(4.dp))
FieldError(R.string.error_reminder_without_due)
FieldError(R.string.error_reminder_without_date)
}
}
@@ -688,18 +697,73 @@ private fun EditContent(
}
if (showReminderPicker) {
var fromStart by rememberSaveable { mutableStateOf(state.start != null) }
var minuteOfDay by rememberSaveable { mutableIntStateOf(state.allDayMinuteOfDay) }
var pickingTime by rememberSaveable { mutableStateOf(false) }
ReminderLeadPicker(
title = stringResource(R.string.edit_reminder_add),
selected = ReminderOverride.Inherit,
allowInherit = false,
allowNone = false,
allDay = state.isAllDay,
header = if (bothDates || state.isAllDay) {
{
if (bothDates) {
ReminderAnchorToggle(fromStart = fromStart, onChange = { fromStart = it })
Spacer(Modifier.height(16.dp))
}
if (state.isAllDay) {
GroupedRow(
title = stringResource(R.string.reminder_time),
position = Position.Alone,
trailing = {
Text(
formatMinuteOfDay(minuteOfDay, LocalUse24HourFormat.current),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
)
},
onClick = { pickingTime = true },
)
Spacer(Modifier.height(24.dp))
}
}
} else {
null
},
onSelect = { choice ->
(choice as? ReminderOverride.Minutes)?.minutes?.firstOrNull()?.let(viewModel::addReminder)
(choice as? ReminderOverride.Minutes)?.minutes?.firstOrNull()?.let { minutes ->
// A task with one date counts from that one, whatever the toggle said.
val anchorStart = if (bothDates) fromStart else state.due == null
viewModel.addReminder(
if (state.isAllDay) {
// The default time is left unnamed, so the reminder follows the setting.
TaskReminder.allDay(
daysBefore = minutes / DAY_MINUTES,
minuteOfDay = minuteOfDay.takeIf { it != state.allDayMinuteOfDay },
fromStart = anchorStart,
)
} else {
TaskReminder(minutes, fromStart = anchorStart)
},
)
}
showReminderPicker = false
},
onDismiss = { showReminderPicker = false },
)
if (pickingTime) {
TimePickerAlert(
initial = LocalTime.of(minuteOfDay / 60, minuteOfDay % 60),
onConfirm = {
val picked = it.hour * 60 + it.minute
// The default time stays unnamed; any other is one an all-day reminder can name.
minuteOfDay = if (picked == state.allDayMinuteOfDay) picked else TaskReminder.allDayMinuteOfDay(picked)
pickingTime = false
},
onDismiss = { pickingTime = false },
)
}
}
if (showRecurrencePicker) {
@@ -3,6 +3,8 @@ package de.jeanlucmakiola.agendula.ui.edit
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.prefs.DEFAULT_ALL_DAY_REMINDER_MINUTE
import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
import de.jeanlucmakiola.agendula.data.tasks.TaskConflictException
@@ -16,6 +18,10 @@ 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.TaskReminder
import de.jeanlucmakiola.agendula.domain.taskReminderDisplayOrder
import de.jeanlucmakiola.agendula.data.tasks.StoredReminders
import java.time.ZoneId
import de.jeanlucmakiola.agendula.domain.populatedFields
import de.jeanlucmakiola.agendula.domain.htmlToPlainText
import de.jeanlucmakiola.agendula.domain.looksLikeHtml
@@ -54,7 +60,13 @@ data class TaskEditUiState(
val priority: Priority = Priority.NONE,
val parentId: Long? = null,
val percentComplete: Int? = null,
val reminders: List<Int> = emptyList(),
val reminders: List<TaskReminder> = emptyList(),
/** Whether the user added or removed a reminder; until then the defaults follow the form. */
val remindersTouched: Boolean = false,
/** Whether [reminders] are stored as they are — see [TaskForm.remindersChosen]. */
val remindersChosen: Boolean = false,
/** The time an all-day reminder without one of its own rings at. */
val allDayMinuteOfDay: Int = DEFAULT_ALL_DAY_REMINDER_MINUTE,
val location: String = "",
val url: String = "",
val tags: List<String> = emptyList(),
@@ -124,6 +136,20 @@ class TaskEditViewModel @Inject constructor(
/** Sections shown by default (a setting); the rest unfold via [revealField]. */
private var defaultFields: Set<TaskFormField> = emptySet()
/** The settings as the form was bound, for the defaults an all-day switch swaps in. */
private var settings: Settings = Settings()
/** The reminders before the last all-day switch, and what it made of them. */
private var lastSwitch: Pair<List<TaskReminder>, List<TaskReminder>>? = null
/** The reminders the task was loaded with, and the dates it had then. */
private var storedReminders: Set<TaskReminder> = emptySet()
/** [storedReminders] as the task's current kind, timed or all-day. */
private var loadedReminders: List<TaskReminder> = emptyList()
private var loadedStart: Instant? = null
private var loadedDue: Instant? = null
/** Start a fresh task, optionally pre-selecting a list / parent. */
fun bindNew(
presetListId: Long? = null,
@@ -138,6 +164,7 @@ class TaskEditViewModel @Inject constructor(
baselineLastModified = null
viewModelScope.launch {
val settings = settingsPrefs.settings.first()
this@TaskEditViewModel.settings = settings
defaultFields = settings.defaultEditFields
val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
val preset = presetFilterId?.let { settingsPrefs.savedFilter(it).first() }
@@ -157,6 +184,7 @@ class TaskEditViewModel @Inject constructor(
loading = false,
isNew = true,
autofocusTitle = settings.autofocusTitle,
allDayMinuteOfDay = settings.allDayReminderMinuteOfDay,
listId = defaultList,
// 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.
@@ -174,7 +202,7 @@ class TaskEditViewModel @Inject constructor(
parentCandidates = loadParents(defaultList, selfId = null),
title = initialTitle.orEmpty(),
tags = (listOfNotNull(presetTag) + preset?.tags.orEmpty()).distinct(),
),
).withDefaultReminders(),
).also { baseline = it.toForm().copy(title = "") }
}
}
@@ -186,7 +214,9 @@ class TaskEditViewModel @Inject constructor(
editingTaskId = taskId
editingOccurrence = occurrenceStart
viewModelScope.launch {
defaultFields = settingsPrefs.settings.first().defaultEditFields
val settings = settingsPrefs.settings.first()
this@TaskEditViewModel.settings = settings
defaultFields = settings.defaultEditFields
val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
val task = runCatching { repository.taskDetail(taskId, occurrenceStart).first()?.task }.getOrNull()
if (task == null) {
@@ -194,6 +224,10 @@ class TaskEditViewModel @Inject constructor(
return@launch
}
baselineLastModified = task.lastModified
loadedReminders = repository.remindersFor(taskId).map { it.lead }.distinct()
storedReminders = loadedReminders.toSet()
loadedStart = task.start
loadedDue = task.due
_state.value = withFields(
TaskEditUiState(
loading = false,
@@ -208,7 +242,9 @@ class TaskEditViewModel @Inject constructor(
priority = task.priority,
parentId = task.parentId,
percentComplete = task.percentComplete,
reminders = repository.remindersFor(taskId).filter { !it.fromStart }.map { it.minutesBefore },
reminders = loadedReminders
.sortedWith(taskReminderDisplayOrder(task.isAllDay, settings.allDayReminderMinuteOfDay)),
allDayMinuteOfDay = settings.allDayReminderMinuteOfDay,
location = task.location.orEmpty(),
url = task.url.orEmpty(),
tags = task.tags,
@@ -271,35 +307,90 @@ class TaskEditViewModel @Inject constructor(
/** Switching list invalidates the parent (it lived in the old list); reload candidates. */
fun onListChange(listId: Long) {
if (_state.value.listId == listId) return // re-picking the same list keeps the parent
update { it.copy(listId = listId, parentId = null, errors = emptySet()) }
update { it.copy(listId = listId, parentId = null, errors = emptySet()).withDefaultReminders() }
viewModelScope.launch {
val candidates = loadParents(listId, selfId = editingTaskId)
update { it.copy(parentCandidates = candidates) }
}
}
fun onStartChange(value: Instant?) = update { it.copy(start = value) }
fun onDueChange(value: Instant?) = update { it.copy(due = value) }
fun onStartChange(value: Instant?) = update { it.copy(start = value).withDefaultReminders() }
fun onDueChange(value: Instant?) = update { it.copy(due = value).withDefaultReminders() }
/**
* All-day and timed values use different conventions (UTC midnight vs. a real
* instant in the device zone), so the switch has to move the timestamps too —
* flipping the flag alone makes an all-day task read back as "02:00", which
* looks to the user like the time reset itself.
* looks to the user like the time reset itself. The reminders are made
* the new kind's here, so the editor shows what will be stored; switching
* straight back gives the old ones back.
*/
fun onAllDayChange(value: Boolean) = update {
it.copy(
if (it.isAllDay == value) return@update it
val switched = it.copy(
isAllDay = value,
start = it.start?.rebasedForAllDay(value),
due = it.due?.rebasedForAllDay(value),
)
if (!it.remindersTouched) {
loadedReminders = carried(loadedReminders, switched)
return@update switched.withDefaultReminders()
}
switched.copy(reminders = carried(it.reminders, switched).sortedForDisplay(switched))
}
/** [reminders] as [switched]'s kind; straight back after a switch, the ones before it. */
private fun carried(reminders: List<TaskReminder>, switched: TaskEditUiState): List<TaskReminder> {
val previous = lastSwitch
val carried = if (previous != null && previous.second == reminders) {
previous.first
} else {
StoredReminders.switched(reminders, switched.toForm(), settings, nowMillis(), ZoneId.systemDefault())
}
lastSwitch = reminders to carried
return carried
}
/**
* The reminders the task will be saved with while the user has not
* touched them: those it was loaded with, plus the defaults of the dates
* it has gained — all of them, for a new task. Shown so they are noted
* before saving rather than added unseen.
*/
private fun TaskEditUiState.withDefaultReminders(): TaskEditUiState {
if (remindersTouched) return this
val reminders = StoredReminders.withDefaults(
toForm().copy(reminders = loadedReminders),
loadedStart,
loadedDue,
settings,
nowMillis(),
ZoneId.systemDefault(),
).sortedForDisplay(this)
val visible = if (reminders.isEmpty()) visibleFields else visibleFields + TaskFormField.Reminder
return copy(
reminders = reminders,
// A new task's, or defaults added to the ones it had: stored as shown.
remindersChosen = isNew || reminders.toSet() != storedReminders,
visibleFields = visible,
hiddenFields = hiddenOf(visible),
)
}
private fun nowMillis(): Long = Clock.System.now().toEpochMilliseconds()
fun onPriorityChange(value: Priority) = update { it.copy(priority = value) }
fun onPercentChange(value: Int?) = update { it.copy(percentComplete = value?.coerceIn(0, 100)) }
fun onParentChange(parentId: Long?) = update { it.copy(parentId = parentId) }
fun addReminder(minutesBeforeDue: Int) = update {
it.copy(reminders = (it.reminders + minutesBeforeDue).distinct().sorted(), errors = emptySet())
fun addReminder(reminder: TaskReminder) = update {
it.copy(
reminders = (it.reminders + reminder).distinct().sortedForDisplay(it),
remindersTouched = true,
remindersChosen = true,
errors = emptySet(),
)
}
fun removeReminder(reminder: TaskReminder) = update {
it.copy(reminders = it.reminders - reminder, remindersTouched = true, remindersChosen = true)
}
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 = "") }
@@ -382,6 +473,10 @@ class TaskEditViewModel @Inject constructor(
}
}
/** The order the editor lists [state]'s reminders in. */
private fun List<TaskReminder>.sortedForDisplay(state: TaskEditUiState): List<TaskReminder> =
sortedWith(taskReminderDisplayOrder(state.isAllDay, state.allDayMinuteOfDay))
/** Snapshot the editable state as a validated [TaskForm]. */
private fun TaskEditUiState.toForm(): TaskForm = TaskForm(
title = title,
@@ -394,6 +489,7 @@ private fun TaskEditUiState.toForm(): TaskForm = TaskForm(
parentId = parentId,
percentComplete = percentComplete,
reminders = reminders,
remindersChosen = remindersChosen,
location = location.ifBlank { null },
url = url.ifBlank { null },
tags = withDraft(tagDraft),
@@ -70,6 +70,7 @@ private const val EXTERNAL_STORAGE = "com.android.externalstorage.documents"
@Composable
fun ExportScreen(
onBack: () -> Unit,
visible: Boolean,
modifier: Modifier = Modifier,
viewModel: ExportViewModel = hiltViewModel(),
) {
@@ -89,6 +90,8 @@ fun ExportScreen(
title = stringResource(R.string.settings_export),
onBack = onBack,
modifier = modifier,
predictiveBack = true,
visible = visible,
) {
Text(
text = stringResource(R.string.export_hint),
@@ -76,6 +76,8 @@ private val PICK_TYPES = arrayOf("text/calendar", "text/x-vcalendar", "applicati
fun ImportScreen(
onBack: () -> Unit,
modifier: Modifier = Modifier,
predictiveBack: Boolean = false,
visible: Boolean? = null,
initialUri: Uri? = null,
onOpenStorage: (() -> Unit)? = null,
viewModel: ImportViewModel = hiltViewModel(),
@@ -105,6 +107,8 @@ fun ImportScreen(
title = stringResource(R.string.import_title),
onBack = onBack,
modifier = modifier,
predictiveBack = predictiveBack,
visible = visible,
) {
Text(
text = stringResource(R.string.import_hint),
@@ -25,12 +25,14 @@ import de.jeanlucmakiola.floret.components.positionOf
* source, which is the specific thing §3.2(a) requires us to point at.
*/
@Composable
internal fun LicencesScreen(onBack: () -> Unit) {
internal fun LicencesScreen(onBack: () -> Unit, visible: Boolean) {
val uriHandler = LocalUriHandler.current
CollapsingScaffold(
title = stringResource(R.string.licences_title),
onBack = onBack,
predictiveBack = true,
visible = visible,
) {
Text(
stringResource(R.string.licences_intro),
@@ -3,12 +3,14 @@ package de.jeanlucmakiola.agendula.ui.navigation
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.IntOffset
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import de.jeanlucmakiola.agendula.domain.Task
import kotlin.time.Instant
@@ -27,6 +29,7 @@ import de.jeanlucmakiola.agendula.ui.lists.ListsScreen
import de.jeanlucmakiola.agendula.ui.settings.SettingsScreen
import de.jeanlucmakiola.agendula.ui.tasklist.TaskListScreen
import de.jeanlucmakiola.agendula.ui.tasklist.TaskListViewModel
import de.jeanlucmakiola.floret.identity.predictiveBackExit
/**
* The single in-app back stack: lists → task list → detail / edit.
@@ -37,6 +40,7 @@ import de.jeanlucmakiola.agendula.ui.tasklist.TaskListViewModel
* `SavedStateHandle`). Screens receive plain navigation lambdas so they stay
* unaware of the nav library.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun AgendulaNavHost(
modifier: Modifier = Modifier,
@@ -70,19 +74,19 @@ fun AgendulaNavHost(
onNavRequestConsumed()
}
val slideSpec = MaterialTheme.motionScheme.defaultSpatialSpec<IntOffset>()
val overlayExit = predictiveBackExit()
NavHost(
navController = nav,
startDestination = Dest.LISTS,
modifier = modifier,
// Only the screen on top moves; the background is always held static.
// Forward is a plain fade-in over the held screen — calm, not a slide.
// Back is the predictive-back peek: navigation-compose composes the
// screen being revealed underneath and seeks popExit with the gesture, so
// the leaving screen scales down to reveal the (unmoved) previous screen.
enterTransition = { fadeIn() },
// Calendula's overlay motion. NavHost's own back handler seeks the exit
// over the previous screen, so destinations don't add a preview of their own.
enterTransition = { slideInHorizontally(slideSpec) { it } + fadeIn() },
exitTransition = { ExitTransition.None },
popEnterTransition = { EnterTransition.None },
popExitTransition = { scaleOut(targetScale = 0.9f) + fadeOut() },
popExitTransition = { overlayExit },
) {
composable(Dest.LISTS) {
ReadableWidth {
@@ -34,6 +34,7 @@ internal fun AppearanceScreen(
state: SettingsUiState,
viewModel: SettingsViewModel,
onBack: () -> Unit,
visible: Boolean,
) {
val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
var showTheme by remember { mutableStateOf(false) }
@@ -42,7 +43,12 @@ internal fun AppearanceScreen(
val systemIs24Hour = DateFormat.is24HourFormat(LocalContext.current)
val locale = currentLocale()
CollapsingScaffold(title = stringResource(R.string.settings_section_appearance), onBack = onBack) {
CollapsingScaffold(
title = stringResource(R.string.settings_section_appearance),
onBack = onBack,
predictiveBack = true,
visible = visible,
) {
GroupedRow(
title = stringResource(R.string.settings_theme),
summary = stringResource(themeLabel(state.settings.themeMode)),
@@ -0,0 +1,37 @@
package de.jeanlucmakiola.agendula.ui.settings
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
/** Backup & restore: the way into export and import, as its own entry (after Calendula). */
@Composable
internal fun BackupScreen(
onOpenExport: () -> Unit,
onOpenImport: () -> Unit,
onBack: () -> Unit,
visible: Boolean,
) {
CollapsingScaffold(
title = stringResource(R.string.settings_section_backup),
onBack = onBack,
predictiveBack = true,
visible = visible,
) {
GroupedRow(
title = stringResource(R.string.settings_export),
summary = stringResource(R.string.settings_export_hint),
position = Position.Top,
onClick = onOpenExport,
)
GroupedRow(
title = stringResource(R.string.import_title),
summary = stringResource(R.string.settings_import_hint),
position = Position.Bottom,
onClick = onOpenImport,
)
}
}
@@ -41,6 +41,8 @@ import androidx.core.net.toUri
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.prefs.SNOOZE_PRESETS
import de.jeanlucmakiola.agendula.data.reminders.TaskNotifier
import de.jeanlucmakiola.agendula.domain.TaskReminderOrder
import de.jeanlucmakiola.agendula.domain.asTaskReminder
import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.agendula.ui.common.OnResume
import de.jeanlucmakiola.agendula.ui.common.canScheduleExactAlarms
@@ -48,7 +50,7 @@ import de.jeanlucmakiola.agendula.ui.common.openExactAlarmSettings
import de.jeanlucmakiola.agendula.ui.common.ReminderDefaultPicker
import de.jeanlucmakiola.agendula.ui.common.TimePickerAlert
import de.jeanlucmakiola.agendula.ui.common.formatMinuteOfDay
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel
import de.jeanlucmakiola.agendula.ui.common.reminderLabel
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.OptionPicker
@@ -57,16 +59,18 @@ import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.identity.collapseExit
import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.floret.reminders.ReminderLead
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
import java.time.LocalTime
/** Whether due tasks notify at all, how far ahead, and which lists differ. */
@Composable
internal fun RemindersScreen(
internal fun NotificationsScreen(
state: SettingsUiState,
viewModel: SettingsViewModel,
onBack: () -> Unit,
visible: Boolean,
) {
val context = LocalContext.current
var notificationsAllowed by remember { mutableStateOf(context.notificationsAllowed()) }
@@ -119,7 +123,12 @@ internal fun RemindersScreen(
)
val lists = state.lists.filter { it.id > 0L }
CollapsingScaffold(title = stringResource(R.string.settings_section_reminders), onBack = onBack) {
CollapsingScaffold(
title = stringResource(R.string.settings_section_reminders),
onBack = onBack,
predictiveBack = true,
visible = visible,
) {
if (state.settings.remindersEnabled && !notificationsAllowed) {
GroupedRow(
title = stringResource(R.string.settings_notifications_blocked),
@@ -153,13 +162,13 @@ internal fun RemindersScreen(
}
GroupedRow(
title = stringResource(R.string.settings_default_reminder),
summary = reminderChoiceLabel(state.settings.defaultReminderMinutes),
summary = reminderChoiceLabel(state.settings.defaultReminders),
position = Position.Middle,
onClick = { showOffset = true },
)
GroupedRow(
title = stringResource(R.string.settings_default_reminder_allday),
summary = reminderChoiceLabel(state.settings.defaultAllDayReminderMinutes, allDay = true),
summary = reminderChoiceLabel(state.settings.defaultAllDayReminders, allDay = true),
position = Position.Middle,
onClick = { showAllDay = true },
)
@@ -245,7 +254,7 @@ internal fun RemindersScreen(
title = stringResource(R.string.settings_default_reminder),
summary = listOverrideSummary(
state.settings.perListReminderOverride.reminderOverrideFor(list.id),
state.settings.defaultReminderMinutes,
state.settings.defaultReminders,
allDay = false,
),
position = Position.Middle,
@@ -255,7 +264,7 @@ internal fun RemindersScreen(
title = stringResource(R.string.settings_default_reminder_allday),
summary = listOverrideSummary(
state.settings.perListAllDayReminderOverride.reminderOverrideFor(list.id),
state.settings.defaultAllDayReminderMinutes,
state.settings.defaultAllDayReminders,
allDay = true,
),
position = Position.Middle,
@@ -278,24 +287,24 @@ internal fun RemindersScreen(
if (showOffset) {
ReminderDefaultPicker(
title = stringResource(R.string.settings_default_reminder),
selected = state.settings.defaultReminderMinutes.let {
if (it.isEmpty()) ReminderOverride.None else ReminderOverride.Minutes(it)
selected = state.settings.defaultReminders.let {
if (it.isEmpty()) ReminderOverride.None else ReminderOverride.Minutes.of(it)
},
allowInherit = false,
onSelect = { viewModel.setDefaultReminderMinutes((it as? ReminderOverride.Minutes)?.minutes.orEmpty()) },
onSelect = { viewModel.setDefaultReminders((it as? ReminderOverride.Minutes)?.leads.orEmpty()) },
onDismiss = { showOffset = false },
)
}
if (showAllDay) {
ReminderDefaultPicker(
title = stringResource(R.string.settings_default_reminder_allday),
selected = state.settings.defaultAllDayReminderMinutes.let {
if (it.isEmpty()) ReminderOverride.None else ReminderOverride.Minutes(it)
selected = state.settings.defaultAllDayReminders.let {
if (it.isEmpty()) ReminderOverride.None else ReminderOverride.Minutes.of(it)
},
allowInherit = false,
allDay = true,
leadTimeSummary = allDayFiresAt,
onSelect = { viewModel.setDefaultAllDayReminderMinutes((it as? ReminderOverride.Minutes)?.minutes.orEmpty()) },
onSelect = { viewModel.setDefaultAllDayReminders((it as? ReminderOverride.Minutes)?.leads.orEmpty()) },
onDismiss = { showAllDay = false },
)
}
@@ -350,19 +359,27 @@ private data class OverrideTarget(val listId: Long, val allDay: Boolean)
/** Row summary for a list: its override, or the inherited global default. */
@Composable
private fun listOverrideSummary(choice: ReminderOverride, globalDefault: List<Int>, allDay: Boolean): String =
private fun listOverrideSummary(choice: ReminderOverride, globalDefault: List<ReminderLead>, allDay: Boolean): String =
when (choice) {
ReminderOverride.Inherit ->
stringResource(R.string.settings_list_reminder_inherits, reminderChoiceLabel(globalDefault, allDay))
ReminderOverride.None -> stringResource(R.string.reminder_none)
is ReminderOverride.Minutes -> reminderChoiceLabel(choice.minutes, allDay)
is ReminderOverride.Minutes -> reminderChoiceLabel(choice.leads, allDay)
}
/** "30 minutes before, At due time", or "No reminder" for an empty set. */
/**
* "30 minutes before, At due time", or "No reminder" for an empty set. Each
* names its date once any counts from the start.
*/
@Composable
private fun reminderChoiceLabel(minutes: List<Int>, allDay: Boolean = false): String =
if (minutes.isEmpty()) stringResource(R.string.reminder_none)
else minutes.map { reminderLeadTimeLabel(it, allDay) }.joinToString(", ")
private fun reminderChoiceLabel(leads: List<ReminderLead>, allDay: Boolean = false): String {
if (leads.isEmpty()) return stringResource(R.string.reminder_none)
val reminders = leads.map { it.asTaskReminder() }.sortedWith(TaskReminderOrder)
val anchored = reminders.any { it.fromStart }
return reminders.map { reminder ->
reminderLabel(reminder.minutesBefore, allDay, reminder.fromStart, anchored)
}.joinToString(", ")
}
/** SCHEDULE_EXACT_ALARM is user-granted from API 31; below that exact alarms need no grant. */
private fun showExactAlarmRow(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
@@ -1,11 +1,8 @@
package de.jeanlucmakiola.agendula.ui.settings
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
@@ -13,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Backup
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.Favorite
@@ -25,6 +23,7 @@ import androidx.compose.material.icons.filled.Translate
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.rounded.CloudSync
import androidx.compose.material.icons.rounded.Storage
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -37,6 +36,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -50,20 +50,22 @@ import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.LanguagePickerRow
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.crash.CrashReportDialog
import de.jeanlucmakiola.floret.crash.CrashReporter
import de.jeanlucmakiola.floret.crash.openIssueTracker
import de.jeanlucmakiola.floret.crash.submitCrashReport
import de.jeanlucmakiola.floret.locale.AppLanguage
import de.jeanlucmakiola.floret.identity.predictiveBackExit
/** The settings sub-screens reached from the hub's category rows. */
internal enum class SettingsSection {
Appearance,
TaskForm,
Reminders,
Notifications,
Storage,
Backup,
Export,
Import,
Accounts,
@@ -72,10 +74,10 @@ internal enum class SettingsSection {
Licences,
;
/** Where back goes: Export is opened from Storage, the account screens from Accounts. */
/** The section that stays composed underneath: Backup under export and import, Accounts under its screens. */
val parent: SettingsSection?
get() = when (this) {
Export, Import -> Storage
Export, Import -> Backup
AddAccount, Account -> Accounts
else -> null
}
@@ -88,8 +90,9 @@ internal enum class SettingsSection {
* A full-screen destination; [onBack] pops it. [initialSection] names a
* sub-screen to open on (see [de.jeanlucmakiola.agendula.ui.navigation.Dest.Settings]).
*
* Every sub-screen is its own file. This one owns the hub, the section enum and
* the back rules between them, and nothing else.
* Every sub-screen is its own file and handles its own back through the
* predictive-back preview. This one owns the hub, the section enum and the
* routing between them, and nothing else.
*/
@Composable
fun SettingsScreen(
@@ -106,10 +109,8 @@ fun SettingsScreen(
else SettingsSection.entries.firstOrNull { it.name == initialSection },
)
}
// Inside a sub-screen, system back (button or gesture) returns to the hub
// rather than popping the whole Settings destination to the lists overview.
BackHandler(enabled = section != null) { section = section?.parent }
// Import's "open storage" comes back to Import rather than the hub.
var storageReturn by rememberSaveable { mutableStateOf<SettingsSection?>(null) }
Box(
modifier = modifier
@@ -118,34 +119,58 @@ fun SettingsScreen(
) {
SettingsHub(onBack = onBack, onOpenSection = { section = it })
SlideInSection(visible = section == SettingsSection.Appearance) {
AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null })
val appearance = section == SettingsSection.Appearance
SlideInSection(visible = appearance) {
AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = appearance)
}
SlideInSection(visible = section == SettingsSection.TaskForm) {
TaskFormScreen(state = state, viewModel = viewModel, onBack = { section = null })
val taskForm = section == SettingsSection.TaskForm
SlideInSection(visible = taskForm) {
TaskFormScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = taskForm)
}
SlideInSection(visible = section == SettingsSection.Reminders) {
RemindersScreen(state = state, viewModel = viewModel, onBack = { section = null })
val notifications = section == SettingsSection.Notifications
SlideInSection(visible = notifications) {
NotificationsScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = notifications)
}
// Storage stays composed under Export, so the deeper screen slides over it.
val storageOpen = section == SettingsSection.Storage ||
section?.parent == SettingsSection.Storage
SlideInSection(visible = storageOpen) {
val storage = section == SettingsSection.Storage
SlideInSection(visible = storage) {
StorageScreen(
viewModel = viewModel,
onBack = {
section = storageReturn
storageReturn = null
},
visible = storage,
)
}
// Backup stays composed under export and import, so the deeper screen slides over it.
val backup = section == SettingsSection.Backup || section?.parent == SettingsSection.Backup
SlideInSection(visible = backup) {
BackupScreen(
onOpenExport = { section = SettingsSection.Export },
onOpenImport = { section = SettingsSection.Import },
onBack = { section = null },
visible = backup,
)
}
SlideInSection(visible = section == SettingsSection.Export) {
ExportScreen(onBack = { section = SettingsSection.Storage })
val exportOpen = section == SettingsSection.Export
SlideInSection(visible = exportOpen) {
ExportScreen(onBack = { section = SettingsSection.Backup }, visible = exportOpen)
}
SlideInSection(visible = section == SettingsSection.Import) {
ImportScreen(onBack = { section = SettingsSection.Storage })
val importOpen = section == SettingsSection.Import
SlideInSection(visible = importOpen) {
ImportScreen(
onBack = { section = SettingsSection.Backup },
onOpenStorage = {
storageReturn = SettingsSection.Import
section = SettingsSection.Storage
},
predictiveBack = true,
visible = importOpen,
)
}
SlideInSection(visible = section == SettingsSection.Licences) {
LicencesScreen(onBack = { section = null })
val licences = section == SettingsSection.Licences
SlideInSection(visible = licences) {
LicencesScreen(onBack = { section = null }, visible = licences)
}
AccountSections(
section = section,
@@ -155,12 +180,18 @@ fun SettingsScreen(
}
}
/**
* A sub-screen layered over the hub, with Calendula's overlay motion: it slides
* in on the motion scheme's spring and leaves from its predictive-back preview.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
internal fun SlideInSection(visible: Boolean, content: @Composable () -> Unit) {
val slideSpec = MaterialTheme.motionScheme.defaultSpatialSpec<IntOffset>()
AnimatedVisibility(
visible = visible,
enter = slideInHorizontally { it } + fadeIn(),
exit = slideOutHorizontally { it } + fadeOut(),
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = predictiveBackExit(),
) { content() }
}
@@ -208,7 +239,7 @@ private fun SettingsHub(
summary = stringResource(R.string.settings_reminders_subtitle),
position = Position.Bottom,
leading = { CategoryIcon(Icons.Default.Notifications, ChipAccent.Primary) },
onClick = { onOpenSection(SettingsSection.Reminders) },
onClick = { onOpenSection(SettingsSection.Notifications) },
)
Spacer(Modifier.height(8.dp))
@@ -225,14 +256,41 @@ private fun SettingsHub(
GroupedRow(
title = stringResource(R.string.settings_section_storage),
summary = stringResource(R.string.settings_storage_subtitle),
position = if (BuildConfig.SYNC_ENABLED) Position.Bottom else Position.Alone,
leading = { CategoryIcon(Icons.Rounded.Storage, ChipAccent.Secondary) },
position = if (BuildConfig.SYNC_ENABLED) Position.Middle else Position.Top,
leading = { CategoryIcon(Icons.Rounded.Storage, ChipAccent.Primary) },
onClick = { onOpenSection(SettingsSection.Storage) },
)
GroupedRow(
title = stringResource(R.string.settings_section_backup),
summary = stringResource(R.string.settings_backup_subtitle),
position = Position.Bottom,
leading = { CategoryIcon(Icons.Default.Backup, ChipAccent.Secondary) },
onClick = { onOpenSection(SettingsSection.Backup) },
)
Spacer(Modifier.height(8.dp))
GroupedSectionHeader(stringResource(R.string.settings_group_app))
LanguageRow(position = Position.Top)
LanguagePickerRow(
position = Position.Top,
title = stringResource(R.string.settings_language),
autoLabel = stringResource(R.string.settings_language_auto),
localesConfig = R.xml.locales_config,
predictiveBack = true,
// Invite contributions right where users pick their language.
header = {
val context = LocalContext.current
val translateUrl = stringResource(R.string.about_translate_url)
GroupedRow(
title = stringResource(R.string.settings_translate),
summary = stringResource(R.string.settings_translate_hint),
position = Position.Alone,
leading = { CategoryIcon(Icons.Default.Translate, ChipAccent.Neutral) },
onClick = { openUrl(context, translateUrl) },
)
Spacer(Modifier.height(16.dp))
},
leading = { CategoryIcon(Icons.Default.Language, ChipAccent.Secondary) },
)
ReportProblemRow(position = Position.Bottom)
// Source, licence and privacy sit at the bottom as reference material
@@ -316,64 +374,6 @@ private fun AboutLinkRow(label: String, url: String, icon: ImageVector, position
)
}
/**
* The app-language row. Deliberately not floret-kit's `LanguagePickerRow`: the
* picker it opens carries a "Help translate" header, and inviting contributions
* right where a user goes looking for their language is app-specific framing,
* not a family primitive. Everything else matches that recipe.
*/
@Composable
private fun LanguageRow(position: Position) {
val context = LocalContext.current
val supported = remember { AppLanguage.supportedTags(context, R.xml.locales_config) }
// Setting a locale recreates the activity; mirror the choice locally so the
// row updates instantly even before the recreation lands.
var current by remember { mutableStateOf(AppLanguage.currentTag(supported)) }
var showDialog by remember { mutableStateOf(false) }
// null = follow the system; the rest are BCP-47 tags from locales_config.xml.
val options = remember(supported) { listOf<String?>(null) + supported }
GroupedRow(
title = stringResource(R.string.settings_language),
summary = languageLabel(current),
position = position,
leading = { CategoryIcon(Icons.Default.Language, ChipAccent.Secondary) },
onClick = { showDialog = true },
)
if (showDialog) {
OptionPicker(
title = stringResource(R.string.settings_language),
predictiveBack = true,
options = options,
selected = current,
label = { languageLabel(it) },
onSelect = {
current = it
AppLanguage.apply(it)
},
onDismiss = { showDialog = false },
// Invite contributions right where users pick their language.
header = {
val translateUrl = stringResource(R.string.about_translate_url)
GroupedRow(
title = stringResource(R.string.settings_translate),
summary = stringResource(R.string.settings_translate_hint),
position = Position.Alone,
leading = { CategoryIcon(Icons.Default.Translate, ChipAccent.Neutral) },
onClick = { openUrl(context, translateUrl) },
)
Spacer(Modifier.height(16.dp))
},
)
}
}
@Composable
private fun languageLabel(tag: String?): String =
if (tag == null) stringResource(R.string.settings_language_auto) else AppLanguage.displayName(tag)
/**
* Opens the project's issue tracker to report a problem. If a crash report was
* captured (and not yet sent), it surfaces that report first via the same
@@ -22,6 +22,7 @@ import de.jeanlucmakiola.agendula.data.tasks.transfer.TransferCounts
import de.jeanlucmakiola.agendula.data.tasks.transfer.TransferResult
import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.floret.reminders.ReminderLead
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
@@ -219,12 +220,12 @@ class SettingsViewModel @Inject constructor(
fun setThemeMode(mode: ThemeMode) = viewModelScope.launch { prefs.setThemeMode(mode) }
fun setDynamicColor(enabled: Boolean) = viewModelScope.launch { prefs.setDynamicColor(enabled) }
fun setDefaultList(id: Long?) = viewModelScope.launch { prefs.setDefaultListId(id) }
fun setDefaultReminderMinutes(minutes: List<Int>) = viewModelScope.launch {
prefs.setDefaultReminderMinutes(minutes)
fun setDefaultReminders(leads: List<ReminderLead>) = viewModelScope.launch {
prefs.setDefaultReminders(leads)
resyncReminders()
}
fun setDefaultAllDayReminderMinutes(minutes: List<Int>) = viewModelScope.launch {
prefs.setDefaultAllDayReminderMinutes(minutes)
fun setDefaultAllDayReminders(leads: List<ReminderLead>) = viewModelScope.launch {
prefs.setDefaultAllDayReminders(leads)
resyncReminders()
}
fun setAllDayReminderMinuteOfDay(minuteOfDay: Int) = viewModelScope.launch {
@@ -55,9 +55,8 @@ import de.jeanlucmakiola.floret.components.SelectedCheck
@Composable
internal fun StorageScreen(
viewModel: SettingsViewModel,
onOpenExport: () -> Unit,
onOpenImport: () -> Unit,
onBack: () -> Unit,
visible: Boolean,
) {
val context = LocalContext.current
val storage by viewModel.storage.collectAsStateWithLifecycle()
@@ -85,11 +84,17 @@ internal fun StorageScreen(
// permission dialog too, and would wipe the refusal before it is read.
OnResume { viewModel.refreshStorage() }
CollapsingScaffold(title = stringResource(R.string.settings_section_storage), onBack = onBack) {
CollapsingScaffold(
title = stringResource(R.string.settings_section_storage),
onBack = onBack,
predictiveBack = true,
visible = visible,
) {
val canCopy = storage?.canCopyFromExternal == true
GroupedRow(
title = stringResource(R.string.settings_task_store),
summary = storage?.let { storeLabel(it) },
position = Position.Top,
position = if (canCopy) Position.Top else Position.Alone,
// Nothing to pick until the stored mode has landed; opening the picker
// on the seeded state would offer the wrong store as the current one.
onClick = storage?.let {
@@ -99,31 +104,19 @@ internal fun StorageScreen(
}
},
)
if (storage?.canCopyFromExternal == true) {
if (canCopy) {
GroupedRow(
title = stringResource(
R.string.settings_copy_from_external,
storage?.let { externalTitle(it.external, it.externalLabel) }.orEmpty(),
),
summary = stringResource(R.string.settings_copy_from_external_hint),
position = Position.Middle,
position = Position.Bottom,
leading = { Icon(Icons.Rounded.MoveDown, contentDescription = null) },
// Inert while a copy is in flight; the receipt below says so.
onClick = if (transfer.running) null else viewModel::startCopyFromExternal,
)
}
GroupedRow(
title = stringResource(R.string.settings_export),
summary = stringResource(R.string.settings_export_hint),
position = Position.Middle,
onClick = onOpenExport,
)
GroupedRow(
title = stringResource(R.string.import_title),
summary = stringResource(R.string.settings_import_hint),
position = Position.Bottom,
onClick = onOpenImport,
)
TransferStatus(transfer)
@@ -42,10 +42,16 @@ internal fun TaskFormScreen(
state: SettingsUiState,
viewModel: SettingsViewModel,
onBack: () -> Unit,
visible: Boolean,
) {
var showDefaultList by remember { mutableStateOf(false) }
CollapsingScaffold(title = stringResource(R.string.settings_section_task_form), onBack = onBack) {
CollapsingScaffold(
title = stringResource(R.string.settings_section_task_form),
onBack = onBack,
predictiveBack = true,
visible = visible,
) {
SettingsHint(stringResource(R.string.settings_form_fields_hint))
Spacer(Modifier.height(8.dp))
val fields = TaskFormField.entries
+559 -4
View File
@@ -7,8 +7,8 @@
<string name="tasks_title">المهام</string>
<string name="task_list_empty">لا مهام هنا بعد.</string>
<string name="task_list_failure">تعذَّر تحميل هذه المهام.</string>
<string name="smart_no_date">لا تاريخ</string>
<string name="smart_completed">مكتمل</string>
<string name="smart_no_date">بلا موعد</string>
<string name="smart_completed">المكتملة</string>
<string name="task_detail_title">المهمة</string>
<string name="task_detail_not_found">لم تعد هذه المهمة موجودة.</string>
<string name="edit">تعديل</string>
@@ -26,7 +26,7 @@
<string name="section_overdue">متأخر</string>
<string name="section_today">اليوم</string>
<string name="section_upcoming">القادم</string>
<string name="section_no_date">لا تاريخ</string>
<string name="section_no_date">بلا موعد</string>
<string name="section_completed">مكتمل</string>
<string name="priority_none">لا شئ</string>
<string name="priority_low">منخفض</string>
@@ -71,7 +71,7 @@
<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_theme_system">اتباع النظام</string>
<string name="settings_dynamic_color">اللون الديناميكي</string>
<string name="settings_reminders">التذكيرات المستحقة</string>
<string name="settings_exact_alarms">التوقيت الدقيق</string>
@@ -106,4 +106,559 @@
<string name="settings_report_problem_hint">افتح متعقب المشكلات</string>
<string name="settings_copy_from_external">نسخ المهام من %1$s</string>
<string name="task_completed">مكتمل</string>
<string name="clear_completed_action">مسح</string>
<string name="edit_list_label">القائمة</string>
<string name="edit_all_day">طوال اليوم</string>
<string name="edit_set">تعيين</string>
<string name="edit_clear">مسح</string>
<string name="edit_more_fields">المزيد من الخانات</string>
<string name="close">إغلاق</string>
<string name="dialog_cancel">إلغاء</string>
<string name="reminder_none">لا شئ</string>
<string name="reminder_custom">مخصص</string>
<string name="reminder_custom_set">تعيين</string>
<string name="list_color_purple">بنفسجي</string>
<string name="list_color_pink">وردي</string>
<string name="accounts_lists">القوائم</string>
<string name="shortcut_new_task">مهمة جديدة</string>
<string name="sort_title">فرز حسب</string>
<string name="sort_priority">الأولوية</string>
<string name="sort_title_alpha">العنوان</string>
<string name="sort_created">المُضاف مؤخرًا</string>
<string name="cd_repeats">تكرارات</string>
<string name="clear_completed_title">مسح المهام المكتملة؟</string>
<plurals name="clear_completed_body">
<item quantity="zero">سيتم حذف (%1$d) لا مهام مكتملة في هذه القائمة.</item>
<item quantity="one">سيتم حذف %1$d مهمة مكتملة في هذه القائمة.</item>
<item quantity="two">سيتم حذف %1$d مهمتان مكتملتان في هذه القائمة.</item>
<item quantity="few">سيتم حذف %1$d مهام مكتملة في هذه القائمة.</item>
<item quantity="many">سيتم حذف %1$d مهمة مكتملة في هذه القائمة.</item>
<item quantity="other">سيتم حذف %1$d مهمة مكتملة في هذه القائمة.</item>
</plurals>
<string name="clear_completed_confirm">حذف</string>
<string name="sort_due">موعد الاستحقاق</string>
<string name="detail_due">مستحق</string>
<string name="detail_priority">الأولوية</string>
<string name="detail_progress">التقدم</string>
<string name="detail_subtasks">المهام الفرعية</string>
<string name="detail_parent">المهمة الأم</string>
<string name="detail_part_of">جزء من</string>
<string name="percent_complete">%1$d%%</string>
<string name="edit_start_label">يبدأ</string>
<string name="edit_due_label">مستحق</string>
<string name="edit_priority_label">الأولوية</string>
<string name="edit_progress_label">التقدم</string>
<string name="edit_parent_label">المهمة الأم</string>
<string name="edit_parent_none">لا شيء (المستوى الأعلى)</string>
<string name="edit_parent_search">البحث عن المهام</string>
<string name="edit_parent_empty">لا مهام مطابقة في هذه القائمة.</string>
<string name="edit_reminder_label">التذكير</string>
<string name="edit_title_hint">عنوان المهمة</string>
<string name="edit_conflict_title">المهمة غُيِّ‏‏رت في مكان آخر</string>
<string name="edit_conflict_body">حُدِّثت هذه المهمة في مكان ما آخر (مزامنة أو تطبيق آخر) منذ قمت بفتحها. الكتابة فوق هذه التغييرات بتعديلاتك؟</string>
<string name="edit_conflict_overwrite">استبدال</string>
<string name="reminder_at_due">في وقت المهمة</string>
<string name="reminder_on_the_day">في اليوم</string>
<string name="detail_pane_empty">اختر مهمة لرؤيتها هنا</string>
<string name="reminder_custom_with_value">مخصص (%1$s)</string>
<string name="reminder_custom_amount">أدخل قيمةً</string>
<string name="reminder_use_default">استخدام الافتراضي</string>
<string name="reminder_unit_minutes">دقائق</string>
<string name="reminder_unit_hours">ساعات</string>
<string name="reminder_unit_days">أيام</string>
<string name="reminder_unit_weeks">أسابيع</string>
<string name="settings_list_reminders_title">التذكيرات لكل قائمة</string>
<string name="settings_list_reminders_hint">تجاوز التذكير الافتراضي للقوائم الفردية</string>
<string name="lists_reorder_handle">اسحب لإعادة الترتيب</string>
<string name="settings_list_notifications_hint">الصوت، الاهتزاز و الأهمية لتذكيرات هذه القائمة</string>
<string name="smart_today">اليوم</string>
<string name="home_due_today">اليوم</string>
<string name="reminder_day_today">اليوم</string>
<string name="recurrence_title">التكرار</string>
<string name="recurrence_every">كل</string>
<string name="recurrence_daily">كل يوم</string>
<string name="recurrence_weekly">كل أسبوع</string>
<string name="recurrence_monthly">كل شهر</string>
<string name="recurrence_yearly">كل سنة</string>
<plurals name="accounts_sync_every_hours">
<item quantity="zero">كل %d ساعة</item>
<item quantity="one">كل ساعة</item>
<item quantity="two">كل ساعتان</item>
<item quantity="few">كل %d ساعات</item>
<item quantity="many">كل %d ساعة</item>
<item quantity="other">كل %d ساعة</item>
</plurals>
<plurals name="recurrence_every_n_days">
<item quantity="zero">كل %1$d يوم</item>
<item quantity="one">كل %1$d يوم</item>
<item quantity="two">كل %1$d يومان</item>
<item quantity="few">كل %1$d أيام</item>
<item quantity="many">كل %1$d يوم</item>
<item quantity="other">كل %1$d يوم</item>
</plurals>
<plurals name="recurrence_every_n_weeks">
<item quantity="zero">كل %1$d أسبوع</item>
<item quantity="one">كل %1$d أسبوع</item>
<item quantity="two">كل %1$d أسبوعان</item>
<item quantity="few">كل %1$d أسابيع</item>
<item quantity="many">كل %1$d أسبوع</item>
<item quantity="other">كل %1$d أسبوع</item>
</plurals>
<plurals name="recurrence_every_n_months">
<item quantity="zero">كل %1$d شهر</item>
<item quantity="one">كل %1$d شهر</item>
<item quantity="two">كل %1$d شهران</item>
<item quantity="few">كل %1$d أشهر</item>
<item quantity="many">كل %1$d شهر</item>
<item quantity="other">كل %1$d شهر</item>
</plurals>
<plurals name="recurrence_every_n_years">
<item quantity="zero">كل %1$d سنة</item>
<item quantity="one">كل %1$d سنة</item>
<item quantity="two">كل %1$d سنتين</item>
<item quantity="few">كل %1$d سنوات</item>
<item quantity="many">كل %1$d سنة</item>
<item quantity="other">كل %1$d سنة</item>
</plurals>
<plurals name="accounts_sync_every_minutes">
<item quantity="zero">كل %d دقيقة</item>
<item quantity="one">كل %d دقيقة</item>
<item quantity="two">كل %d دقيقتان</item>
<item quantity="few">كل %d دقائق</item>
<item quantity="many">كل %d دقيقة</item>
<item quantity="other">كل %d دقيقة</item>
</plurals>
<string name="lists_header">القوائم</string>
<string name="smart_upcoming">القادمة</string>
<string name="smart_all">الكل</string>
<string name="smart_overdue">المتأخرة</string>
<string name="home_due_tomorrow">غدًا</string>
<string name="recurrence_custom">مخصص</string>
<string name="recurrence_unit_days">أيام</string>
<string name="recurrence_unit_weeks">أسابيع</string>
<string name="recurrence_unit_months">شهور</string>
<string name="recurrence_unit_years">سنوات</string>
<string name="recurrence_ends">ينتهي</string>
<string name="recurrence_end_never">أبدًا</string>
<string name="recurrence_times">مرّات</string>
<string name="edit_recurrence_label">التكرار</string>
<string name="edit_location_label">الموقع</string>
<string name="edit_url_label">الرابط</string>
<string name="edit_url_hint">https://…</string>
<string name="edit_discard_confirm">تجاهل</string>
<string name="progress_fraction_compact">%1$d/%2$d</string>
<string name="detail_status_cancelled">ملغي</string>
<string name="detail_duplicate">تكرار</string>
<string name="list_color">اللون</string>
<string name="list_color_mauve">بنفسجي باهت</string>
<string name="list_color_red">أحمر</string>
<string name="list_color_orange">برتقالي</string>
<string name="list_color_amber">أصفر كهرماني</string>
<string name="list_color_olive">زيتوني</string>
<string name="list_color_green">أخضر</string>
<string name="list_color_teal">أخضر مزرق</string>
<string name="list_color_cyan">سماوي</string>
<string name="list_color_blue">أزرق</string>
<string name="list_color_indigo">نيلي</string>
<string name="reminder_day_tomorrow">غدًا</string>
<string name="reminder_day_yesterday">يوم أمس</string>
<string name="reminder_action_done">تم</string>
<string name="onboarding_list_default_name">شخصي</string>
<string name="onboarding_backup_continue">متابعة</string>
<string name="onboarding_quick_add_done">إنهاء</string>
<string name="settings_store_switch_confirm">تبديل</string>
<string name="settings_copy_confirm_action">نسخ</string>
<string name="export_running">جارٍ التصدير…</string>
<string name="export_open">فتح</string>
<string name="import_open_storage">التخزين</string>
<string name="import_running">جارٍ الاستيراد…</string>
<string name="import_button">استيراد</string>
<string name="accounts_removing">جارٍ الإزالة…</string>
<string name="add_account_summary_done">تم</string>
<string name="sync_notice_body">%1$s</string>
<string name="sync_notice_retry">إعادة المحاولة</string>
<string name="add_account_server_hint">https://cloud.example.com</string>
<string name="add_account_continue">متابعة</string>
<string name="new_task">مهمة جديدة</string>
<string name="home_upcoming_view_all">عرض الكل</string>
<string name="home_search_hint">ابحث عن المهام</string>
<string name="home_search_clear">مسح البحث</string>
<string name="reminder_channel_name">تذكيرات المهمة</string>
<string name="reminder_onboarding_enable_button">تفعيل التذكيرات</string>
<string name="reminder_onboarding_skip_button">ليس الآن</string>
<string name="app_name">Agendula</string>
<string name="recurrence_generic">مهمة متكرّرة</string>
<string name="recurrence_incomplete">أدخل رقمًا من ١ إلى ٩٩٩</string>
<string name="recurrence_next">التالي: %1$s</string>
<string name="recurrence_next_none">هذه القاعدة لا تتكرر أبدًا</string>
<string name="recurrence_end_until">في موعد</string>
<string name="recurrence_end_count">بعد عدد من المرّات</string>
<string name="edit_discard_title">تجاهل التغييرات؟</string>
<string name="edit_keep_editing">متابعة التعديل</string>
<string name="edit_reminder_add">إضافة تذكير</string>
<string name="edit_reminder_remove">إزالة التذكير</string>
<string name="detail_delete_title">حذف المهمة؟</string>
<string name="detail_status_in_process">قيد التقدم</string>
<string name="more_options">المزيد من الخيارات</string>
<string name="detail_cancel_task">إلغاء المهمة</string>
<string name="detail_restore">استعادة المهمة</string>
<string name="list_add">قائمة جديدة</string>
<string name="list_new_title">قائمة جديدة</string>
<string name="list_edit_title">تعديل القائمة</string>
<string name="list_name_hint">اسم القائمة</string>
<string name="list_delete">حذف القائمة</string>
<string name="list_delete_confirm_title">حذف القائمة؟</string>
<string name="onboarding_sync_skip">ليس الآن</string>
<string name="onboarding_list_create">إنشاء قائمة</string>
<string name="licences_title">التراخيص مفتوحة المصدر</string>
<string name="sync_notification_channel">جارٍ المزامنة</string>
<string name="add_account_password_label">كلمة المرور</string>
<string name="add_account_lists_read_only">للقراءة فقط</string>
<string name="widget_tasks_label">مهام</string>
<string name="filter_section_priority">الأولوية</string>
<string name="filter_delete_confirm">حذف</string>
<string name="widget_today_label">اليوم</string>
<string name="edit_when">متى</string>
<string name="crash_report_issue_title">تقرير العطل</string>
<string name="import_title">استيراد المهام</string>
<string name="import_picked_file">الملف المُختار</string>
<string name="import_target_label">استيراد إلى</string>
<string name="import_new_list_default_name">المهام المستوردة</string>
<string name="accounts_remove">إزالة الحساب</string>
<string name="accounts_never_synced">لم تتم المزامنة مطلقًا</string>
<string name="accounts_last_sync">آخر مزامنة</string>
<string name="accounts_generic_provider">حساب CalDAV</string>
<string name="accounts_sync_now">زامن الآن</string>
<string name="add_account_email_label">عنوان البريد الإلكتروني</string>
<string name="add_account_credentials_title">تسجيل الدخول</string>
<string name="add_account_working_signing_in">جارٍ تسجيل الدخول</string>
<string name="sync_notice_channel_name">تقارير المزامنة</string>
<string name="sync_notices_dismiss">فهمت</string>
<string name="sync_notification_title">جارٍ مزامنة المهام</string>
<string name="add_account_server_label">عنوان الخادم</string>
<string name="add_account_start_over">البدء من جديد</string>
<string name="add_account_username_label">اسم المستخدم</string>
<string name="add_account_sign_in">تسجيل الدخول</string>
<string name="add_account_browser_retry">حاول مرة أخرى</string>
<string name="add_account_save">إضافة حساب</string>
<string name="edit_tags_label">الوسوم</string>
<string name="tags_header">الوسوم</string>
<string name="filter_section_tags">الوسوم</string>
<string name="edit_tags_hint">أضف وسمًا</string>
<string name="home_today_empty">لا شيء مستحق اليوم 🎉</string>
<plurals name="duration_minutes">
<item quantity="zero">(%d) لا دقائق</item>
<item quantity="one">%d دقيقة واحده</item>
<item quantity="two">%d دقيقتان</item>
<item quantity="few">%d دقائق</item>
<item quantity="many">%d دقيقة</item>
<item quantity="other">%d دقيقة</item>
</plurals>
<plurals name="reminder_minutes">
<item quantity="zero">(%1$d) لا دقائق قبل</item>
<item quantity="one">%1$d دقيقة واحده قبل</item>
<item quantity="two">%1$d دقيقتان قبل</item>
<item quantity="few">%1$d دقائق قبل</item>
<item quantity="many">%1$d دقيقة قبل</item>
<item quantity="other">%1$d دقيقة قبل</item>
</plurals>
<plurals name="widget_up_next_in_hours">
<item quantity="zero">في %1$d ساعة</item>
<item quantity="one">في %1$d ساعة</item>
<item quantity="two">في %1$d ساعتان</item>
<item quantity="few">في %1$d ساعات</item>
<item quantity="many">في %1$d ساعة</item>
<item quantity="other">في %1$d ساعة</item>
</plurals>
<plurals name="widget_up_next_in_days">
<item quantity="zero">في %1$d يوم</item>
<item quantity="one">في %1$d يوم</item>
<item quantity="two">في %1$d يومان</item>
<item quantity="few">في %1$d أيام</item>
<item quantity="many">في %1$d يوم</item>
<item quantity="other">في %1$d يوم</item>
</plurals>
<plurals name="duration_hours">
<item quantity="zero">(%d) لا ساعات</item>
<item quantity="one">%d ساعة واحده</item>
<item quantity="two">%d ساعتان</item>
<item quantity="few">%d ساعات</item>
<item quantity="many">%d ساعة</item>
<item quantity="other">%d ساعة</item>
</plurals>
<plurals name="reminder_hours">
<item quantity="zero">(%1$d) لا ساعات قبل</item>
<item quantity="one">%1$d ساعة واحده قبل</item>
<item quantity="two">%1$d ساعتان قبل</item>
<item quantity="few">%1$d ساعات قبل</item>
<item quantity="many">%1$d ساعة قبل</item>
<item quantity="other">%1$d ساعة قبل</item>
</plurals>
<plurals name="widget_up_next_overdue_hours">
<item quantity="zero">متأخر %1$d ساعة</item>
<item quantity="one">متأخر %1$d ساعة</item>
<item quantity="two">متأخر %1$d ساعتان</item>
<item quantity="few">متأخر %1$d ساعات</item>
<item quantity="many">متأخر %1$d ساعة</item>
<item quantity="other">متأخر %1$d ساعة</item>
</plurals>
<plurals name="widget_up_next_overdue_days">
<item quantity="zero">متأخر %1$d يوم</item>
<item quantity="one">متأخر %1$d يوم</item>
<item quantity="two">متأخر %1$d يومان</item>
<item quantity="few">متأخر %1$d أيام</item>
<item quantity="many">متأخر %1$d يوم</item>
<item quantity="other">متأخر %1$d يوم</item>
</plurals>
<string name="widget_up_next_soon">قريباً</string>
<string name="widget_up_next_tomorrow">غدًا</string>
<string name="widget_up_next_then">ثم</string>
<string name="widget_up_next_empty">لا شئ مستحق</string>
<string name="widget_up_next_any_list">جميع القوائم</string>
<string name="widget_today_nothing_due">لا شيء مستحق اليوم</string>
<plurals name="reminder_days">
<item quantity="zero">(%1$d) لا أيام قبل</item>
<item quantity="one">%1$d يوم واحد قبل</item>
<item quantity="two">%1$d يومان قبل</item>
<item quantity="few">%1$d أيام قبل</item>
<item quantity="many">%1$d يوم قبل</item>
<item quantity="other">%1$d يوم قبل</item>
</plurals>
<string name="error_due_before_start">الاستحقاق لا يمكنه أن يكون قبل البدء.</string>
<string name="filter_section_due">الاستحقاق</string>
<string name="widget_section_overdue">متأخرة</string>
<string name="reminder_due_at">مستحق %1$s</string>
<string name="filter_any_due">أي موعد استحقاق</string>
<string name="view_options_title">الفرز و التصفية</string>
<string name="view_filter_clear">مسح عامل التصفية</string>
<string name="filters_header">عوامل التصفية</string>
<string name="filter_new_title">عامل تصفية جديد</string>
<string name="filter_edit_title">تعديل عامل تصفية</string>
<string name="filter_name_hint">اسم عامل التصفية</string>
<string name="filter_any_list">أي قائمة</string>
<string name="filter_any_tag">أي وسم، أو بلا</string>
<string name="filter_priority_any">أي أولوية</string>
<string name="filter_priority_low">منخفض وأعلى</string>
<string name="filter_priority_medium">متوسط وأعلى</string>
<string name="filter_priority_high">عالي فقط</string>
<string name="filter_delete_title">حذف ”%1$s“؟</string>
<string name="filter_delete">حذف عامل التصفية</string>
<string name="filter_missing">تم حذف عامل التصفية</string>
<string name="view_filter_tags">تصفية حسب الوسم</string>
<string name="filter_section_lists">القوائم</string>
<string name="settings_appearance_subtitle">السمة، اللون و تنسيق الوقت</string>
<string name="settings_task_form_subtitle">الحقول، القائمة والمهام الفرعية الافتراضية</string>
<string name="recurrence_on_days">%1$s في %2$s</string>
<string name="recurrence_with_until">%1$s حتى %2$s</string>
<plurals name="recurrence_with_count">
<item quantity="zero">%1$s، %2$d مرة</item>
<item quantity="one">%1$s، %2$d مرة</item>
<item quantity="two">%1$s، %2$d مرتان</item>
<item quantity="few">%1$s، %2$d مرّات</item>
<item quantity="many">%1$s، %2$d مرة</item>
<item quantity="other">%1$s، %2$d مرة</item>
</plurals>
<string name="recurrence_none">لا يتكرر</string>
<string name="recurring_scope_occurrence">فقط هذه المهمة</string>
<string name="recurring_scope_following">هذه والمهام اللاحقة</string>
<string name="recurring_scope_series">جميع المهام في السلسلة</string>
<string name="error_blank_title">أدخل عنوانًا.</string>
<string name="error_no_list">اختر قائمة.</string>
<string name="error_recurrence_without_date">تحتاج مهمة متكررة لموعد بدء أو استحقاق.</string>
<string name="edit_recurrence_none">لا يتكرر</string>
<string name="edit_tag_remove">إزالة الوسم %1$s</string>
<string name="edit_discard_body">لم يتم حفظ تغييراتك لهذه المهمة.</string>
<string name="edit_scope_title">حفظ التغييرات لمهمة متكررة</string>
<string name="edit_reminder_add_first">أضف تذكيرًا (بخلاف ذلك الافتراضي ينطبق)</string>
<string name="subtask_progress">%1$d / %2$d</string>
<string name="detail_delete_body">سيتم حذف هذه المهمة.</string>
<plurals name="detail_delete_body_subtasks">
<item quantity="zero">سيتم حذف هذه المهمة و (%1$d) لا مهام الفرعية خاصّتها.</item>
<item quantity="one">سيتم حذف هذه المهمة و المهمة الفرعية خاصّتها.</item>
<item quantity="two">سيتم حذف هذه المهمة و المهمتان الفرعيتان خاصّتها.</item>
<item quantity="few">سيتم حذف هذه المهمة و الـ %1$d مهام الفرعية خاصّتها.</item>
<item quantity="many">سيتم حذف هذه المهمة و %1$d المهمة الفرعية خاصّتها.</item>
<item quantity="other">سيتم حذف هذه المهمة و %1$d المهمة الفرعية خاصّتها.</item>
</plurals>
<string name="detail_delete_recurring_title">حذف المهمة المتكررة</string>
<string name="filters_empty_hint">اجمع بين مواعيد الاستحقاق، القوائم، الوسوم والأولوية إلى طريقة عرض من خاصّتك</string>
<string name="filter_match_all_off">تحتاج مهمة إلى إحدى الوسوم التي اخترتها</string>
<string name="filter_match_all_tags">مطابقة كل الوسوم</string>
<string name="home_today_all_done">انتهى كل شيء 🎉</string>
<string name="lists_failure">تعذّر قراءة مهامك.</string>
<string name="list_save_failed">تعذّر حفظ القائمة.</string>
<string name="list_delete_failed">تعذّر حذف القائمة.</string>
<string name="settings_copy_failed">تعذّر نسخ المهام. لا شيء تغيّر — لم يتم المساس بالنسخ الأصلية خاصّتك.</string>
<string name="settings_legacy_import_failed">تعذّر نقل مهامك السابقة</string>
<string name="export_failed">تعذّر كتابة التصدير</string>
<string name="export_failed_folder">تعذّر فتح المجلد المُختار</string>
<string name="export_failed_create">تعذّر إنشاء ملف في المجلد المُختار</string>
<string name="import_failed_unreadable">تعذّر قراءة الملف كـ iCalendar</string>
<string name="import_failed_write">تعذّر حفظ المهام</string>
<string name="add_account_error_not_saved">تعذّر حفظ الحساب.</string>
<string name="list_save_failed_offline">تعذّر الوصول للخادم. حاول مرة أخرى عندما تعود متصلًا.</string>
<string name="list_save_failed_read_only">تتم مشاركة هذه القائمة معك للقراءة فقط.</string>
<string name="list_save_failed_unsupported">لا يسمح هذا الحساب التطبيقات بإنشاء قوائم مهام.</string>
<string name="settings_legacy_import_failed_hint">لا تزال محفوظة ولا شئ فُقد. اضغط لمحاولة نقلهم مرة أخرى.</string>
<string name="export_failed_read_only">المجلد المختار غير قابل للكتابة</string>
<string name="export_failed_access">فُقد الوصول إلى الموقع المُختار</string>
<string name="import_failed_no_tasks">الملف لا يحتوي مهام</string>
<string name="accounts_sync_failed">لم تنتهي آخر مزامنة</string>
<string name="accounts_failed_sign_in">فشلت المزامنة — لم يقبل الخادم كلمة المرور</string>
<string name="accounts_failed_unreachable">فشلت المزامنة — تعذّر الوصول للخادم</string>
<string name="accounts_failed_certificate">فشلت المزامنة — شهادة الخادم غير موثوقة</string>
<string name="accounts_failed_server">فشلت المزامنة — أبلغ الخادم عن خطأ</string>
<string name="accounts_failed_server_code">فشلت المزامنة — أبلغ الخادم عن خطأ (%1$d)</string>
<string name="accounts_failed_misconfigured">فشلت المزامنة — الحساب غير مكتمل؛ أزله وأضفه مجددًا</string>
<string name="add_account_browser_failed_title">لم تنتهي عملية تسجيل الدخول</string>
<string name="add_account_browser_error_failed">لم ينتهي الخادم تسجيل دخولك. حاول مجددًا بعد لحظة.</string>
<string name="accounts_push_account_on">تصل التغييرات فورًا</string>
<string name="add_account_provider_other_header">أي خادم CalDAV</string>
<string name="add_account_provider_other">خادم CalDAV آخر</string>
<string name="add_account_lists_shared">تم مُشارك معك</string>
<string name="add_account_provider_title">أين مهامك؟</string>
<string name="add_account_email_title">ما عنوانك هناك؟</string>
<string name="add_account_server_title">أين مهامك؟</string>
<string name="add_account_working_reading_lists">جاري قراءة قوائم المهام خاصّتك</string>
<string name="add_account_browser_title">جاري الانتظار لمتصفحك</string>
<string name="add_account_browser_use_password">استخدم كلمة مرور بدلاً</string>
<string name="add_account_browser_open">فتح صفحة تسجيل الدخول</string>
<string name="add_account_lists_title">أيّ القوائم يجب المزامنة؟</string>
<string name="add_account_provider_unsupported">لا دعم للمهام عبر CalDAV</string>
<string name="add_account_working_discovering">جارٍ البحث عن خادم CalDAV</string>
<string name="add_account_setup_icloud_title">يحتاج iCloud كلمة مرور خاصة بتطبيق</string>
<string name="add_account_setup_fastmail_title">يحتاج Fastmail كلمة مرور تطبيق</string>
<string name="add_account_no_lists_selected">اختر على الأقل قائمة واحدة</string>
<string name="add_account_error_already_exists">تم إعداد هذا الحساب بالفعل.</string>
<string name="add_account_error_keystore">لن يقوم هذا الجهاز بتخزين كلمة المرور.</string>
<string name="add_account_browser_confirm_title">تحقق من هذا قبل تسجيل الدخول</string>
<string name="add_account_error_credentials_rejected">لم يتم قبول اسم المستخدم أو كلمة المرور هذه.</string>
<plurals name="add_account_summary_lists">
<item quantity="zero">يزامن %1$d قائمة</item>
<item quantity="one">يزامن %1$d قائمة</item>
<item quantity="two">يزامن %1$d قائمتان</item>
<item quantity="few">يزامن %1$d قوائم</item>
<item quantity="many">يزامن %1$d قائمة</item>
<item quantity="other">يزامن %1$d قائمة</item>
</plurals>
<plurals name="accounts_push_account_partial">
<item quantity="zero">فوري لـ %1$d من (%2$d) لا قوائم</item>
<item quantity="one">فوري لـ %1$d من %2$d قائمة</item>
<item quantity="two">فوري لـ %1$d من %2$d قائمتان</item>
<item quantity="few">فوري لـ %1$d من %2$d قوائم</item>
<item quantity="many">فوري لـ %1$d من %2$d قائمة</item>
<item quantity="other">فوري لـ %1$d من %2$d قائمة</item>
</plurals>
<string name="add_account_provider_body">اختر الخدمة التي تعيش عليها قوائم مهامك. يتزامن Agendula مع أي شيء يدعم CalDAV.</string>
<string name="add_account_email_body">عنوان البريد الإلكتروني الذي تستخدمه مع هذه الخدمة. يعثر Agendula على الخادم بنفسه.</string>
<string name="add_account_summary_body">ستبقي Agendula هذه القوائم في مزامنة من الآن فصاعدًا. يمكنك تغيير ما يُزامن في الإعدادات.</string>
<string name="add_account_server_body">أدخل عنوان الخادم CalDAV خاصّتك. عنوان بريد إلكتروني يعمل أيضًا، إذا كان الخادم ينشر نفسه تحت نطاقه.</string>
<string name="add_account_credentials_body">استخدم كلمة مرور تطبيق إذا كان المزود يقدم واحدة — يمكن إلغاؤها دون تغيير كلمة مرور حسابك.</string>
<string name="add_account_lists_body">اختر قوائم المهام للإبقاء في مزامنة. يمكنك تغيير هذا لاحقًا.</string>
<string name="add_account_error_not_an_address">هذا لا يبدو كعنوان ويب. جرّب شيئًا مثل cloud.example.com.</string>
<string name="add_account_error_not_dav">استجاب هذا العنوان، لكنه ليس خادم CalDAV. إذا قمت بلصق موقع الويب، فجرّب عنوان الخادم الذي يوفره المزود للتقويمات.</string>
<string name="add_account_error_no_calendar">لا يوفر هذا الخادم تقويمات. تحقق من العنوان مع مزودك.</string>
<string name="add_account_error_unreachable">تعذّر الوصول لهذا الخادم. تحقق من العنوان واتصالك.</string>
<string name="add_account_error_insecure">هذا العنوان غير مشفر. يرسل Agendula كلمة مرورك فقط عبر https.</string>
<string name="add_account_error_no_calendars">سُجِّل الدخول، لكن هذا الحساب لا يحتوي قوائم مهام.</string>
<string name="add_account_error_server">واجه الخادم مشكلة. حاول مجددًا بعد لحظة.</string>
<string name="add_account_working_saving">جارٍ إضافة الحساب</string>
<string name="add_account_error_google_unsupported">لا يدعم تقويم Google المهام عبر CalDAV — كما هو مذكور في مستنداته الخاصة — لذلك Agendula لا يمكنه المزامنة معه.</string>
<string name="add_account_error_cross_domain">يحتفظ هذا الخادم ببعض قوائم المهام على %1$s، التي لا يمكن لـ Agendula تسجيل الدخول إليها بعد.</string>
<string name="add_account_error_no_usable_lists">سُجِّل الدخول، لكن هذا الحساب لا يحتوي قوائم مهام يمكن أن يستخدمها Agendula.</string>
<string name="add_account_browser_error_expired">تم إغلاق نافذة الموافقة قبل أن يرد الخادم.</string>
<string name="add_account_browser_error_rate_limited">يرفض الخادم المحاولات المتكررة. انتظر بضع دقائق وحاول مرة أخرى.</string>
<string name="add_account_browser_error_maintenance">الخادم في وضع الصيانة. حاول مرة أخرى بمجرد عودته.</string>
<string name="add_account_browser_error_unavailable">لا يحتوي هذا الجهاز على متصفح يمكنه فتح صفحة تسجيل الدخول. استخدم كلمة مرور بدلاً.</string>
<string name="add_account_quirk_hint_fastmail">يحتاج Fastmail كلمة مرور تطبيق، ليس كلمة مرور حسابك — وCalDAV ليس على الخطة الأساسية.</string>
<string name="add_account_quirk_hint_icloud">يحتاج iCloud كلمة مرور خاصة بتطبيق، التي يمكنك إنشاؤها في appleid.apple.com مع تفعيل المصادقة الثنائية.</string>
<string name="add_account_setup_body">ستقوم بذلك في متصفحك، ثم العودة هنا لإكمال.</string>
<string name="add_account_setup_icloud_footnote">لن تظهر المهام المحفوظة في iCloud في تطبيق تذكيرات Apple.</string>
<string name="add_account_setup_fastmail_footnote">لا يتم تضمين CalDAV في الخطة الأساسية لـ Fastmail.</string>
<string name="add_account_quirk_google">لا تدعم تقويم Google المهام عبر CalDAV.</string>
<string name="add_account_password_hint">استخدم كلمة مرور تطبيق إذا كان خادمك يوفر واحدة — يمكن إلغاؤها دون تغيير كلمة مرور حسابك.</string>
<string name="add_account_browser_body">وافق على Agendula في المتصفح، ثم ارجع هنا. لا يتم إرسال كلمة مرورك مطلقًا إلى هذا التطبيق — يصدر الخادم كلمة مرور تطبيق منفصلة يمكنك إلغاؤها في أي وقت.</string>
<string name="add_account_browser_insecure">صفحة تسجيل الدخول هذه غير مشفرة (http). أي شيء تكتبه هناك، بما في ذلك كلمة مرور حسابك، ينتقل بشكل غير مشفر.</string>
<string name="add_account_browser_host_mismatch">الخادم أرسلنا إلى %1$s، لكنك كتبت %2$s. يعني ذلك عادةً أن إعداد overwrite.cli.url خاصّته غير صحيح.</string>
<string name="add_account_origin_mismatch">أبلغ الخادم عن عنوانه كـ %1$s، الذي لا يستطيع هذا الجهاز الوصول، لذلك تم استخدام %2$s بدلاً. من المحتمل أن يكون إعداد overwrite.cli.url أو trusted_proxies خاصّته غير صحيح.</string>
<string name="settings_licences_subtitle">المشاريع التي بنيت ‌‌Agendula عليها</string>
<string name="licences_intro">يتضمن Agendula العمل أدناه. اضغط على أي إدخال للحصول على شفرة المصدر الخاصة به.</string>
<string name="licences_footer">يتم تضمين بعض من هذا الكود في شكل معدل. أين يكون هذا هو الحال، يتم إدراج كل تغيير في مستودع المصدر المرتبط أعلاه.</string>
<string name="import_pick">اختر ملفًا</string>
<string name="import_pick_other">اختر ملف آخر</string>
<string name="import_pick_another">اِسترد ملف آخر</string>
<string name="import_reading">جارٍ قراءة الملف…</string>
<plurals name="import_found">
<item quantity="zero">(%1$d) لا مهام</item>
<item quantity="one">%1$d مهمة</item>
<item quantity="two">%1$d مهمتان</item>
<item quantity="few">%1$d مهام</item>
<item quantity="many">%1$d مهمة</item>
<item quantity="other">%1$d مهمة</item>
</plurals>
<plurals name="import_events_left_out">
<item quantity="zero">تم استبعاد (%1$d) لا أحداث تقويم</item>
<item quantity="one">تم استبعاد %1$d حدث تقويم</item>
<item quantity="two">تم استبعاد %1$d حدثان تقويم</item>
<item quantity="few">تم استبعاد %1$d أحداث تقويم</item>
<item quantity="many">تم استبعاد %1$d حدث تقويم</item>
<item quantity="other">تم استبعاد %1$d حدث تقويم</item>
</plurals>
<string name="import_new_list">قائمة جديدة: %1$s</string>
<string name="import_new_list_hint">قائمة جديدة على هذا الجهاز</string>
<plurals name="import_done">
<item quantity="zero">تم استيراد (%1$d) لا مهام إلى %2$s</item>
<item quantity="one">تم استيراد %1$d مهمة إلى %2$s</item>
<item quantity="two">تم استيراد %1$d مهمتان إلى %2$s</item>
<item quantity="few">تم استيراد %1$d مهام إلى %2$s</item>
<item quantity="many">تم استيراد %1$d مهمة إلى %2$s</item>
<item quantity="other">تم استيراد %1$d مهمة إلى %2$s</item>
</plurals>
<plurals name="import_done_skipped_existing">
<item quantity="zero">%1$d موجود بالفعل، تم تخطيه</item>
<item quantity="one">%1$d موجودة بالفعل، تم تخطيها</item>
<item quantity="two">%1$d موجودتان بالفعل، تم تخطيهما</item>
<item quantity="few">%1$d موجودين بالفعل، تم تخطيهم</item>
<item quantity="many">%1$d موجودة بالفعل، تم تخطيهم</item>
<item quantity="other">%1$d موجودة بالفعل، تم تخطيهم</item>
</plurals>
<string name="reminder_action_snooze">تأجيل %1$d دقيقة</string>
<string name="reminder_channel_desc">الإشعارات للمهام التي حان موعدها.</string>
<string name="reminder_onboarding_body">يقوم Agendula بإشعارك عندما يحين موعد شيء ما، لذلك لا داعٍ لأن تحتفظ بأي شيء في رأسك.</string>
<plurals name="reminder_weeks">
<item quantity="zero">(%1$d) لا أسابيع قبل</item>
<item quantity="one">%1$d أسبوع واحد قبل</item>
<item quantity="two">%1$d أسبوعان قبل</item>
<item quantity="few">%1$d أسابيع قبل</item>
<item quantity="many">%1$d أسبوع قبل</item>
<item quantity="other">%1$d أسبوع قبل</item>
</plurals>
<string name="reminder_benefit_delivery_body">كل مهمة مع موعد استحقاق تحصل على إشعارها الخاص.</string>
<string name="reminder_benefit_reversible_body">إيقاف تشغيل التذكيرات وقتما تشاء — أنه فقط مفتاح تبديل.</string>
<string name="lists_empty">لا قوائم مهام بعد.</string>
<string name="accounts_empty_title">لا حسابات حتى الآن</string>
<string name="filter_match_all_on">مهمة تحتاج كل وسم اخترته</string>
<string name="filter_delete_body">فقط عامل التصفية تذهب. تظل مهامه أينما هي.</string>
<string name="widget_empty">على آخر اطلاع بالكل</string>
<string name="widget_lists_empty">لا قوائم للعرض</string>
<string name="widget_section_later">لاحقة</string>
<string name="widget_section_no_date">بلا موعد</string>
<string name="widget_config_smart_lists">القوائم الذكية</string>
<string name="list_in_smart_lists">إظهار في القوائم الذكية</string>
<string name="list_in_smart_lists_hint">تضمين مهام هذه القائمة في اليوم، القادمة، الكل و القوائم الذكية الأخرى</string>
<string name="widget_tasks_description">مهام اليوم، أو أي قائمة، بضغطة لوضع علامة صح عليهم</string>
<string name="widget_empty_hint">لا شئ متبقى للقيام، استمتع به</string>
<string name="settings_time_format_auto_summary">اتباع النظام: %1$s</string>
<plurals name="settings_copy_done">
<item quantity="zero">تم نسخ (%1$d) لا مهام إلى قائمة %2$d. اختر ”على هذا الجهاز“ أعلاه لرؤيتها.</item>
<item quantity="one">تم نسخ %1$d مهمة إلى قائمة %2$d. اختر ”على هذا الجهاز“ أعلاه لرؤيتها.</item>
<item quantity="two">تم نسخ %1$d مهمتان إلى قائمتان %2$d. اختر ”على هذا الجهاز“ أعلاه لرؤيتهما.</item>
<item quantity="few">تم نسخ %1$d مهام إلى قوائم %2$d. اختر ”على هذا الجهاز“ أعلاه لرؤيتهم.</item>
<item quantity="many">تم نسخ %1$d مهمة إلى قائمة %2$d. اختر ”على هذا الجهاز“ أعلاه لرؤيتهم.</item>
<item quantity="other">تم نسخ %1$d مهمة إلى قائمة %2$d. اختر ”على هذا الجهاز“ أعلاه لرؤيتهم.</item>
</plurals>
<string name="home_search_empty">لا مهام مطابقة لـ ”%1$s“</string>
<string name="list_save_failed_server">الخادم لن يقبل هذا التغيير.</string>
<string name="list_delete_confirm_message">سيتم حذف ”%1$s“ وجميع مهامه. هذا لا يمكن التراجع عنه.</string>
<string name="lists_empty_action">إنشاء قائمة</string>
</resources>
+118
View File
@@ -0,0 +1,118 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Agendula</string>
<string name="back">Zpět</string>
<string name="save">Uložit</string>
<string name="delete">Smazat</string>
<string name="tasks_title">Úkoly</string>
<string name="smart_completed">Dokončené</string>
<string name="task_detail_title">Úkol</string>
<string name="task_detail_not_found">Tento úkol již neexistuje.</string>
<string name="edit">Upravit</string>
<string name="field_description">Popis</string>
<string name="edit_save_failed">Nemohu uložit. Zkuste to znovu.</string>
<string name="add_task_hint">Přidat úkol</string>
<string name="task_list_empty">Zatím zde nejsou žádné úkoly.</string>
<string name="task_list_failure">Tyto úkoly se nepodařilo načíst.</string>
<string name="add_subtask_hint">Přidat dílčí úkol</string>
<string name="cd_add_task">Přidat úkol</string>
<string name="cd_complete">Hotovo</string>
<string name="section_overdue">Po termínu</string>
<string name="smart_overdue">Po termínu</string>
<string name="widget_section_overdue">Po termínu</string>
<string name="home_today_empty">Na dnešek žádné úkoly 🎉</string>
<string name="widget_today_nothing_due">Na dnešek žádné úkoly</string>
<string name="edit_parent_search">Hledat úkoly</string>
<string name="home_search_hint">Hledat úkoly</string>
<string name="home_search_empty">Nic nenalezeno pro “%1$s”</string>
<string name="smart_no_date">Bez termínu</string>
<string name="section_no_date">Bez termínu</string>
<string name="edit_conflict_body">Tento úkol byl mezitím změněn jinde (například v jiné aplikaci nebo při synchronizaci). Chcete tyto změny přepsat svými úpravami?</string>
<string name="reminder_benefit_delivery_body">Pro každý úkol s termínem splnění se vytvoří samostatné oznámení.</string>
<string name="edit_all_day">Celý den</string>
<string name="settings_allday_reminder_time">Čas upozornění u celodenních úkolů</string>
<string name="settings_default_reminder_allday">Celodenní úkoly</string>
<string name="edit_discard_body">Změny tohoto úkolu nebyly uloženy.</string>
<string name="edit_tags_label">Tagy</string>
<string name="edit_tags_hint">Přidat tag</string>
<string name="tags_header">Tagy</string>
<string name="filter_section_tags">Tagy</string>
<string name="edit_url_label">Odkaz</string>
<string name="edit_location_label">Poloha</string>
<string name="sort_priority">Priorita</string>
<string name="priority_none">Žádná</string>
<string name="priority_low">Nízká</string>
<string name="priority_medium">Střední</string>
<string name="priority_high">Vysoká</string>
<string name="detail_priority">Priorita</string>
<string name="edit_priority_label">Priorita</string>
<string name="filter_section_priority">Priorita</string>
<string name="filter_priority_high">Jen vysoká</string>
<string name="edit_more_fields">Další pole</string>
<string name="settings_form_fields_hint">Pole, která se zobrazí při vytváření nového úkolu. Ostatní jsou dostupná pod položkou „Další pole“.</string>
<string name="detail_progress">Stav dokončení</string>
<string name="edit_progress_label">Stav dokončení</string>
<string name="edit_discard_title">Zahodit změny?</string>
<string name="edit_discard_confirm">Zahodit</string>
<string name="edit_keep_editing">Pokračovat v úpravách</string>
<string name="settings_privacy">Ochrana soukromí</string>
<string name="settings_license">Licence</string>
<string name="settings_licences">Licence open source</string>
<string name="licences_title">Licence open source</string>
<string name="licences_intro">Agendula obsahuje níže uvedené knihovny a další komponenty. Klepnutím na kteroukoli položku zobrazíte její zdrojový kód.</string>
<string name="settings_about_source">Zdrojový kód</string>
<string name="settings_report_problem">Nahlásit problém</string>
<string name="settings_report_problem_hint">Otevřít systém pro hlášení chyb</string>
<string name="settings_language">Jazyk aplikace</string>
<string name="settings_language_auto">Podle systému</string>
<string name="settings_translate_hint">Přidejte nový jazyk nebo vylepšete překlad na Weblate</string>
<string name="settings_translate">Pomozte s překladem</string>
<string name="onboarding_no_provider_body">Rozhodli jste se ukládat úkoly v jiné aplikaci, ale nemáte nainstalovanou žádnou kompatibilní aplikaci. Nainstalujte OpenTasks nebo tasks.org, případně přepněte zpět na vlastní úložiště aplikace Agendula.</string>
<string name="onboarding_backup_body_later">Pokud nemáte účet, vaše seznamy jsou uložené pouze v tomto zařízení. Kdykoli je můžete exportovat jako soubory iCalendar, které lze otevřít v jiných aplikacích pro úkoly nebo kalendář. Stačí přejít do Nastavení → Úložiště → Exportovat úkoly.</string>
<string name="onboarding_use_own_store">Používat úložiště tohoto zařízení</string>
<string name="settings_section_storage">Úložiště</string>
<string name="settings_storage_subtitle">Kde jsou úkoly uložené a jak je exportovat</string>
<string name="settings_store_own_hint">Vlastní úložiště aplikace Agendula. Není třeba nic dalšího instalovat.</string>
<string name="export_to_folder">Uložit do adresáře</string>
<string name="export_to_zip">Uložit jako ZIP soubor</string>
<string name="settings_export">Exportovat úkoly</string>
<string name="settings_about_support">Podpora vývoje</string>
<string name="settings_section_appearance">Vzhled</string>
<string name="settings_appearance_subtitle">Motiv, barva a formát času</string>
<string name="settings_theme">Motiv</string>
<string name="settings_theme_system">Dle systému</string>
<string name="settings_theme_light">Světlý</string>
<string name="settings_theme_dark">Tmavý</string>
<string name="widget_config_background_solid_hint">Použije pro widget barvu pozadí aktuálního motivu</string>
<string name="widget_config_theme">Motiv</string>
<string name="widget_config_theme_follow_app">Podle aplikace</string>
<string name="widget_config_theme_light">Světlý</string>
<string name="widget_config_theme_dark">Tmavý</string>
<string name="settings_dynamic_color">Dynamické barvy</string>
<string name="settings_dynamic_color_hint">Používat barvy z tapety</string>
<string name="settings_time_format">Formát času</string>
<string name="settings_time_format_auto">Automaticky</string>
<string name="settings_time_format_auto_summary">Podle systému: %1$s</string>
<string name="settings_time_format_12h">12hodinový (2:00 PM)</string>
<string name="settings_time_format_24h">24hodinový formát (14:00)</string>
<string name="settings_week_start">Víkend začíná v</string>
<string name="settings_week_start_auto">Automaticky</string>
<string name="settings_group_look">Vzhled a chování</string>
<string name="add_account_error_not_an_address">To nevypadá jako webová adresa. Zkuste zadat něco jako cloud.example.com.</string>
<string name="add_account_browser_confirm_body">Přihlašovací stránka se nachází na vašem serveru, ne v aplikaci Agendula. Než na ní zadáte heslo ke svému účtu, raději si ověřte, že jí můžete důvěřovat.</string>
<string name="add_account_working_discovering">Vyhledávání CalDAV serveru</string>
<string name="list_add">Nový seznam</string>
<string name="list_new_title">Nový seznam</string>
<string name="list_name_hint">Název seznamu</string>
<string name="list_read_only">Tento sdílený seznam je pouze pro čtení. Jeho název a barvu může měnit jen vlastník.</string>
<string name="onboarding_list_body">Seznamy vám pomohou udržet úkoly přehledně oddělené. Začněte s jedním – další můžete přidávat a tento kdykoli přejmenovat.</string>
<string name="import_new_list_default_name">Importované úkoly</string>
<string name="list_where">Kde</string>
<string name="list_where_device">Jen v tomto zařízení</string>
<string name="settings_copy_from_external_hint">Zkopírujte je do vlastního úložiště Agenduly. Jde o jednorázovou kopii – originály zůstanou tam, kde jsou.</string>
<string name="add_account_server_title">Kde jsou vaše úkoly?</string>
<string name="add_account_provider_title">Kde jsou vaše úkoly?</string>
<string name="list_color">Barva</string>
<string name="widget_config_row_color">Barva řádků</string>
<string name="widget_config_row_color_by_list_hint">Každý řádek je lehce zabarven barvou svého seznamu</string>
</resources>
+11 -1
View File
@@ -78,7 +78,6 @@
<string name="error_blank_title">Gebe einen Titel ein.</string>
<string name="error_no_list">Wähle eine Liste.</string>
<string name="error_due_before_start">Fälligkeit darf nicht vor dem Start liegen.</string>
<string name="error_reminder_without_due">Setze ein Fälligkeits-Datum für Erinnerungen.</string>
<string name="lists_header">Listen</string>
<string name="new_task">Neue Aufgabe</string>
<string name="lists_failure">Deine Aufgaben konnten nicht gelesen werden.</string>
@@ -432,4 +431,15 @@
<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>
<string name="widget_week_label">Woche</string>
<string name="widget_lists_label">Listen</string>
<string name="widget_up_next_any_list">Alle Listen</string>
<string name="widget_up_next_tomorrow">Morgen</string>
<string name="view_options_title">Sortieren und Filter</string>
<string name="view_filter_tags">Filtern nach Tag</string>
<string name="view_filter_clear">Filter zurücksetzen</string>
<string name="recurrence_ends">Endet</string>
<string name="recurring_scope_series">Alle Termine der Serie</string>
<string name="edit_recurrence_label">Wiederholung</string>
<string name="edit_recurrence_none">Wiederholt sich nicht</string>
</resources>
-1
View File
@@ -108,7 +108,6 @@
<string name="error_blank_title">Veuillez saisir un titre.</string>
<string name="error_no_list">Sélectionnez une liste.</string>
<string name="error_due_before_start">La date d\'échéance ne peut pas être antérieure au début.</string>
<string name="error_reminder_without_due">Fixez une date d\'échéance pour pourvoir définir un rappel.</string>
<string name="lists_header">Listes</string>
<string name="new_task">Nouvelle tâche</string>
<string name="lists_failure">Impossible de lire vos tâches.</string>
@@ -79,7 +79,6 @@
<string name="error_blank_title">Insira um título.</string>
<string name="error_no_list">Escolha uma lista.</string>
<string name="error_due_before_start">Prazo não pode ser antes do início.</string>
<string name="error_reminder_without_due">Ajuste um prazo para usar um lembrete.</string>
<string name="lists_header">Listas</string>
<string name="new_task">Nova tarefa</string>
<string name="lists_failure">Não foi possível ler suas tarefas.</string>
+63 -15
View File
@@ -122,8 +122,8 @@
<string name="reminder_unit_days">Days</string>
<string name="reminder_unit_weeks">Weeks</string>
<string name="settings_list_reminders_title">Per-list reminders</string>
<string name="settings_list_reminders_hint">Override the default reminder for individual lists</string>
<string name="settings_list_notifications">Notifications</string>
<string name="settings_list_reminders_hint">Override the default per list — separately for timed and all-day tasks. A list can keep the default, drop it, or set its own.</string>
<string name="settings_list_notifications">Sound &amp; vibration</string>
<string name="settings_list_notifications_hint">Sound, vibration and importance for this list’s reminders</string>
<string name="settings_list_reminder_inherits">Default (%1$s)</string>
@@ -181,7 +181,7 @@
<string name="error_blank_title">Enter a title.</string>
<string name="error_no_list">Choose a list.</string>
<string name="error_due_before_start">Due can\'t be before the start.</string>
<string name="error_reminder_without_due">Set a due date to use a reminder.</string>
<string name="error_reminder_without_date">Set a start or due date to use a reminder.</string>
<string name="error_recurrence_without_date">A repeating task needs a start or due date.</string>
<string name="edit_recurrence_label">Repeat</string>
<string name="edit_recurrence_none">Does not repeat</string>
@@ -199,7 +199,15 @@
<string name="edit_reminder_add">Add reminder</string>
<string name="edit_reminder_add_first">Add a reminder (otherwise the default applies)</string>
<string name="edit_reminder_remove">Remove reminder</string>
<string name="detail_reminder_before_start">%1$s (before start)</string>
<string name="reminder_at_start">At start time</string>
<string name="reminder_at_due_time">At due time</string>
<string name="reminder_on_start_date">On the start date</string>
<string name="reminder_on_due_date">On the due date</string>
<!-- An all-day reminder's day and its time: "On the day at 09:00", "1 day before at 17:00" -->
<string name="reminder_at_time">%1$s at %2$s</string>
<string name="reminder_anchor_start">From start</string>
<string name="reminder_anchor_due">From due date</string>
<string name="reminder_time">Time</string>
<string name="subtask_progress">%1$d / %2$d</string>
<string name="progress_fraction_compact">%1$d/%2$d</string>
<string name="detail_delete_title">Delete task?</string>
@@ -359,7 +367,7 @@
<!-- First run: backup -->
<string name="onboarding_backup_title">Keep a copy</string>
<string name="onboarding_backup_body_later">Without an account, your lists live only on this device. Save them as iCalendar files any other task or calendar app can read, any time from Settings → Storage → Export.</string>
<string name="onboarding_backup_body_later">Without an account, your lists live only on this device. Save them as iCalendar files any other task or calendar app can read, any time from Settings → Backup &amp; restore → Export.</string>
<string name="onboarding_backup_continue">Continue</string>
<!-- First run: quick add bar -->
@@ -394,10 +402,10 @@
<!-- Group of reference links at the foot of the settings hub. -->
<string name="settings_group_about">About</string>
<string name="settings_section_appearance">Appearance</string>
<string name="settings_appearance_subtitle">Theme, colour and time format</string>
<string name="settings_section_task_form">Task form</string>
<string name="settings_task_form_subtitle">Default fields, list and subtasks</string>
<string name="settings_reminders_subtitle">Notifications for due tasks</string>
<string name="settings_appearance_subtitle">Theme, colours, time format</string>
<string name="settings_section_task_form">New task form</string>
<string name="settings_task_form_subtitle">Default fields and behaviour</string>
<string name="settings_reminders_subtitle">Reminders and delivery</string>
<string name="settings_form_fields_hint">Fields shown by default on a new task. The rest sit behind \"More fields\".</string>
<string name="settings_dynamic_color_unavailable">Available on Android 12 and later</string>
<string name="settings_autofocus_title">Focus title on new task</string>
@@ -442,13 +450,13 @@
<string name="settings_theme_dark">Dark</string>
<string name="settings_dynamic_color">Dynamic colour</string>
<string name="settings_dynamic_color_hint">Use colours from your wallpaper</string>
<string name="settings_section_reminders">Reminders</string>
<string name="settings_reminders">Due reminders</string>
<string name="settings_section_reminders">Notifications</string>
<string name="settings_reminders">Task reminders</string>
<string name="settings_reminders_hint">Notify me when a task is due</string>
<string name="settings_notifications_blocked">Notifications are off</string>
<string name="settings_notifications_blocked_hint">Reminders can\u2019t reach you. Tap to allow notifications for Agendula.</string>
<string name="settings_default_reminder">When to remind</string>
<string name="settings_allday_reminder_time">Time for all-day reminders</string>
<string name="settings_default_reminder">Default reminder</string>
<string name="settings_allday_reminder_time">All-day reminder time</string>
<string name="settings_allday_reminder_time_summary">All-day tasks count back from %1$s</string>
<string name="settings_default_reminder_allday">All-day tasks</string>
<string name="settings_allday_reminder_fires_at">Notifies at %1$s</string>
@@ -472,9 +480,11 @@
<!-- Storage and export -->
<string name="settings_section_storage">Storage</string>
<string name="settings_storage_subtitle">Where tasks are kept, and export</string>
<string name="settings_storage_subtitle">Where your tasks are kept</string>
<string name="settings_section_backup">Backup &amp; restore</string>
<string name="settings_backup_subtitle">Export and import</string>
<string name="settings_task_store">Task store</string>
<string name="settings_task_store_hint">Each store keeps its own tasks. Switching does not move them across — copy them over first, or export them.</string>
<string name="settings_task_store_hint">Each store keeps its own tasks. Switching does not move them across — copy them over first, or export them from Backup &amp; restore.</string>
<string name="settings_store_own">On this device</string>
<string name="settings_store_own_hint">Agendula\'s own storage. Nothing else to install.</string>
<string name="settings_store_external">Another task app</string>
@@ -594,6 +604,38 @@
<item quantity="one">%1$d week before</item>
<item quantity="other">%1$d weeks before</item>
</plurals>
<plurals name="reminder_minutes_before_start">
<item quantity="one">%1$d minute before the start</item>
<item quantity="other">%1$d minutes before the start</item>
</plurals>
<plurals name="reminder_hours_before_start">
<item quantity="one">%1$d hour before the start</item>
<item quantity="other">%1$d hours before the start</item>
</plurals>
<plurals name="reminder_days_before_start">
<item quantity="one">%1$d day before the start</item>
<item quantity="other">%1$d days before the start</item>
</plurals>
<plurals name="reminder_weeks_before_start">
<item quantity="one">%1$d week before the start</item>
<item quantity="other">%1$d weeks before the start</item>
</plurals>
<plurals name="reminder_minutes_before_due">
<item quantity="one">%1$d minute before the due date</item>
<item quantity="other">%1$d minutes before the due date</item>
</plurals>
<plurals name="reminder_hours_before_due">
<item quantity="one">%1$d hour before the due date</item>
<item quantity="other">%1$d hours before the due date</item>
</plurals>
<plurals name="reminder_days_before_due">
<item quantity="one">%1$d day before the due date</item>
<item quantity="other">%1$d days before the due date</item>
</plurals>
<plurals name="reminder_weeks_before_due">
<item quantity="one">%1$d week before the due date</item>
<item quantity="other">%1$d weeks before the due date</item>
</plurals>
<!-- CalDAV accounts -->
<string name="settings_section_accounts">Accounts</string>
@@ -854,4 +896,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>
@@ -2,9 +2,11 @@ package de.jeanlucmakiola.agendula.data.reminders
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.domain.allDayInstantOf
import de.jeanlucmakiola.agendula.domain.testTask
import de.jeanlucmakiola.agendula.domain.asReminderLead
import de.jeanlucmakiola.floret.reminders.asReminderLeads
import org.junit.jupiter.api.Test
import java.time.LocalDate
import java.time.LocalDateTime
@@ -38,6 +40,28 @@ class ReminderPlannerTest {
assertThat(trigger).isEqualTo(millis(date.minusDays(1).atTime(8, 30), berlin))
}
@Test
fun `a default from before the task existed does not ring`() {
val created = Instant.fromEpochMilliseconds(millis(date.atTime(14, 0), berlin))
val task = testTask(id = 1, due = allDayInstantOf(date), isAllDay = true, created = created)
val plan = ReminderPlanner.plan(
tasks = listOf(task),
perTask = emptyMap(),
settings = Settings(allDayReminderMinuteOfDay = 9 * 60),
now = created.toEpochMilliseconds(),
zone = berlin,
)
assertThat(plan).isEmpty()
val own = ReminderPlanner.plan(
tasks = listOf(task),
perTask = mapOf(1L to listOf(TaskReminder(0))),
settings = Settings(allDayReminderMinuteOfDay = 9 * 60),
now = created.toEpochMilliseconds(),
zone = berlin,
)
assertThat(own).hasSize(1)
}
@Test
fun `a timed reminder counts back from the instant itself`() {
val due = Instant.fromEpochMilliseconds(1_000_000_000L)
@@ -60,6 +84,59 @@ class ReminderPlannerTest {
assertThat(plan).containsExactly(ScheduledReminder(1, millis(date.atTime(7, 0), berlin)))
}
@Test
fun `a reminder another client already dismissed is not fired again`() {
val due = Instant.parse("2026-07-20T10:00:00Z")
val task = testTask(id = 3, due = due)
val trigger = due.toEpochMilliseconds() - 15 * 60_000L
// Inside the missed-reminder grace, which is where a dismissal elsewhere matters.
val now = trigger + 60_000L
fun plan(acknowledged: Instant?) = ReminderPlanner.plan(
tasks = listOf(task),
perTask = mapOf(3L to listOf(TaskReminder(15, acknowledgedAt = acknowledged))),
settings = Settings(),
now = now,
zone = berlin,
)
assertThat(plan(acknowledged = Instant.fromEpochMilliseconds(trigger + 1))).isEmpty()
// Dismissed before this trigger came due: an earlier one, still to fire.
assertThat(plan(acknowledged = Instant.fromEpochMilliseconds(trigger - 1)))
.containsExactly(ScheduledReminder(3, trigger))
assertThat(plan(acknowledged = null)).containsExactly(ScheduledReminder(3, trigger))
}
@Test
fun `a task whose alarms were turned off elsewhere has none, not the default`() {
val due = Instant.parse("2026-07-20T10:00:00Z")
val now = due.toEpochMilliseconds() - 60 * 60_000L
fun plan(perTask: Map<Long, List<TaskReminder>>) = ReminderPlanner.plan(
tasks = listOf(testTask(id = 4, due = due)),
perTask = perTask,
settings = Settings(defaultReminders = listOf(15).asReminderLeads()),
now = now,
zone = berlin,
)
assertThat(plan(mapOf(4L to emptyList()))).isEmpty()
assertThat(plan(emptyMap())).hasSize(1)
}
@Test
fun `an all-day reminder dismissed elsewhere before this device's hour stays dismissed`() {
val task = testTask(id = 5, due = allDayInstantOf(date)).copy(isAllDay = true)
// Rung at midnight on the Mac, dismissed at 07:00; this device rings at 09:00.
val dismissed = date.atTime(7, 0).atZone(berlin).toInstant()
val plan = ReminderPlanner.plan(
tasks = listOf(task),
perTask = mapOf(5L to listOf(TaskReminder(0, acknowledgedAt = Instant.fromEpochMilliseconds(dismissed.toEpochMilli())))),
settings = Settings(allDayReminderMinuteOfDay = 9 * 60),
now = millis(date.atTime(8, 0), berlin),
zone = berlin,
)
assertThat(plan).isEmpty()
}
@Test
fun `an all-day task takes the all-day defaults, not the timed ones`() {
val task = testTask(id = 6, due = allDayInstantOf(date)).copy(isAllDay = true, listId = 7)
@@ -67,8 +144,8 @@ class ReminderPlannerTest {
tasks = listOf(task),
perTask = emptyMap(),
settings = Settings(
defaultReminderMinutes = listOf(5),
defaultAllDayReminderMinutes = listOf(0, 24 * 60),
defaultReminders = listOf(5).asReminderLeads(),
defaultAllDayReminders = listOf(0, 24 * 60).asReminderLeads(),
),
now = millis(date.minusDays(2).atTime(12, 0), berlin),
zone = berlin,
@@ -152,7 +229,7 @@ class ReminderPlannerTest {
val plan = ReminderPlanner.plan(
tasks = listOf(task),
perTask = mapOf(4L to listOf(TaskReminder(0), TaskReminder(60))),
settings = Settings(defaultReminderMinutes = listOf(15)),
settings = Settings(defaultReminders = listOf(15).asReminderLeads()),
now = millis(date.atTime(0, 0), berlin),
zone = berlin,
)
@@ -169,7 +246,7 @@ class ReminderPlannerTest {
val plan = ReminderPlanner.plan(
tasks = listOf(task),
perTask = emptyMap(),
settings = Settings(defaultReminderMinutes = listOf(5, 30)),
settings = Settings(defaultReminders = listOf(5, 30).asReminderLeads()),
now = millis(date.atTime(0, 0), berlin),
zone = berlin,
)
@@ -186,4 +263,65 @@ class ReminderPlannerTest {
val plan = ReminderPlanner.plan(listOf(task), emptyMap(), Settings(), millis(date.atTime(0, 0), berlin), berlin)
assertThat(plan.single().occurrenceStart).isEqualTo(due.toEpochMilliseconds())
}
@Test
fun `an all-day reminder with a time of its own rings at it`() {
val nine = TaskReminder.allDay(daysBefore = 0, minuteOfDay = 9 * 60)
val fivePmBefore = TaskReminder.allDay(daysBefore = 1, minuteOfDay = 17 * 60)
assertThat(ReminderPlanner.triggerAt(allDayInstantOf(date), true, nine.minutesBefore, 7 * 60, berlin))
.isEqualTo(millis(date.atTime(9, 0), berlin))
assertThat(ReminderPlanner.triggerAt(allDayInstantOf(date), true, fivePmBefore.minutesBefore, 7 * 60, berlin))
.isEqualTo(millis(date.minusDays(1).atTime(17, 0), berlin))
}
@Test
fun `several times on one all-day task each ring`() {
val task = testTask(id = 6, due = allDayInstantOf(date)).copy(isAllDay = true)
val reminders = listOf(TaskReminder.allDay(0, 9 * 60), TaskReminder.allDay(0, 17 * 60))
val plan = ReminderPlanner.plan(listOf(task), mapOf(6L to reminders), Settings(), millis(date.atTime(0, 0), berlin), berlin)
assertThat(plan).containsExactly(
ScheduledReminder(6, millis(date.atTime(9, 0), berlin)),
ScheduledReminder(6, millis(date.atTime(17, 0), berlin)),
)
}
@Test
fun `a default counts only from a date the task has`() {
val start = Instant.fromEpochMilliseconds(millis(date.atTime(9, 0), berlin))
val due = Instant.fromEpochMilliseconds(millis(date.atTime(18, 0), berlin))
val both = testTask(id = 7, due = due).copy(start = start)
val dueOnly = testTask(id = 8, due = due)
val startOnly = testTask(id = 9).copy(start = start)
val defaults = listOf(TaskReminder(60, fromStart = true), TaskReminder(0)).map { it.asReminderLead() }
val now = millis(date.atTime(0, 0), berlin)
val plan = ReminderPlanner.plan(
listOf(both, dueOnly, startOnly),
emptyMap(),
Settings(defaultReminders = defaults),
now,
berlin,
)
assertThat(plan).containsExactly(
ScheduledReminder(7, millis(date.atTime(8, 0), berlin)),
ScheduledReminder(7, due.toEpochMilliseconds()),
ScheduledReminder(8, due.toEpochMilliseconds()),
ScheduledReminder(9, millis(date.atTime(8, 0), berlin)),
)
}
@Test
fun `a task's own reminder counts from the other date when its own is missing`() {
val start = Instant.fromEpochMilliseconds(millis(date.atTime(9, 0), berlin))
val task = testTask(id = 10).copy(start = start)
val plan = ReminderPlanner.plan(
listOf(task),
mapOf(10L to listOf(TaskReminder(15))),
Settings(),
millis(date.atTime(0, 0), berlin),
berlin,
)
assertThat(plan).containsExactly(ScheduledReminder(10, millis(date.atTime(8, 45), berlin)))
}
}
@@ -0,0 +1,116 @@
package de.jeanlucmakiola.agendula.data.tasks
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.domain.allDayInstantOf
import de.jeanlucmakiola.agendula.domain.testTask
import de.jeanlucmakiola.agendula.domain.asReminderLead
import de.jeanlucmakiola.floret.reminders.asReminderLeads
import org.junit.jupiter.api.Test
import java.time.LocalDate
import java.time.ZoneId
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
import kotlin.time.Instant
class StoredRemindersTest {
private val zone = ZoneId.of("Europe/Berlin")
private val now = Instant.parse("2026-10-01T10:00:00Z")
private val dayBeforeStart = TaskReminder(1_440, fromStart = true)
private val dayBeforeDue = TaskReminder(1_440)
private val hourBeforeDue = TaskReminder(60)
private val settings = Settings(
defaultReminders = listOf(dayBeforeStart, dayBeforeDue, hourBeforeDue).map { it.asReminderLead() },
)
private fun stored(form: TaskForm, current: de.jeanlucmakiola.agendula.domain.Task? = null, with: Settings = settings) =
StoredReminders.of(form, current, with, now.toEpochMilliseconds(), zone)
@Test
fun `a new task takes the defaults for the dates it has`() {
val form = TaskForm(title = "t", listId = 1, start = now + 2.days, due = now + 5.days)
assertThat(stored(form)).containsExactly(dayBeforeStart, dayBeforeDue, hourBeforeDue)
assertThat(stored(form.copy(start = null))).containsExactly(dayBeforeDue, hourBeforeDue)
assertThat(stored(form.copy(due = null))).containsExactly(dayBeforeStart)
}
@Test
fun `a default already past when the task gets it is left out`() {
val form = TaskForm(title = "t", listId = 1, start = now, due = now + 5.days)
assertThat(stored(form)).containsExactly(dayBeforeDue, hourBeforeDue)
}
@Test
fun `a recurring task keeps it for the occurrences to come`() {
val form = TaskForm(title = "t", listId = 1, start = now, due = now + 5.days, rrule = "FREQ=DAILY")
assertThat(stored(form)).containsExactly(dayBeforeStart, dayBeforeDue, hourBeforeDue)
}
private fun switched(reminders: List<TaskReminder>, form: TaskForm, with: Settings = settings) =
StoredReminders.switched(reminders, form, with, now.toEpochMilliseconds(), zone)
@Test
fun `a date the task gains brings its defaults`() {
val current = testTask(start = now + 2.days)
val form = TaskForm(title = "t", listId = 1, start = now + 2.days, due = now + 5.days, reminders = listOf(dayBeforeStart))
assertThat(stored(form, current)).containsExactly(dayBeforeStart, dayBeforeDue, hourBeforeDue)
val due = testTask(due = now + 5.days)
assertThat(stored(form.copy(reminders = listOf(hourBeforeDue)), due)).containsExactly(hourBeforeDue, dayBeforeStart)
}
@Test
fun `a gained date the form already counts from keeps the form`() {
val current = testTask(start = now + 2.days)
val ownDue = TaskReminder(30)
val form = TaskForm(title = "t", listId = 1, start = now + 2.days, due = now + 5.days, reminders = listOf(ownDue))
assertThat(stored(form, current)).containsExactly(ownDue)
}
@Test
fun `reminders the editor chose are stored as they are`() {
val form = TaskForm(title = "t", listId = 1, start = now + 2.days, due = now + 5.days, remindersChosen = true)
assertThat(stored(form)).isEmpty()
assertThat(stored(form.copy(reminders = listOf(hourBeforeDue)), testTask(due = now + 5.days)))
.containsExactly(hourBeforeDue)
}
@Test
fun `an edit that keeps its dates keeps the form`() {
val current = testTask(due = now + 5.days)
val form = TaskForm(title = "t", listId = 1, due = now + 6.days)
assertThat(stored(form, current)).isEmpty()
}
@Test
fun `a default from before anchors existed swaps when the task turns all-day`() {
val legacy = Settings(defaultReminders = listOf(15).asReminderLeads(), defaultAllDayReminders = listOf(0).asReminderLeads())
val due = allDayInstantOf(LocalDate.of(2026, 10, 3))
val form = TaskForm(title = "t", listId = 1, due = due, isAllDay = true)
assertThat(switched(listOf(TaskReminder(15)), form, legacy)).containsExactly(TaskReminder(0))
}
@Test
fun `only the whole default swaps`() {
val due = allDayInstantOf(LocalDate.of(2026, 10, 3))
val form = TaskForm(title = "t", listId = 1, due = due, isAllDay = true)
assertThat(switched(listOf(dayBeforeDue), form)).containsExactly(dayBeforeDue)
}
@Test
fun `a reminder of its own turns into whole days on an all-day task`() {
val due = allDayInstantOf(LocalDate.of(2026, 10, 3))
val form = TaskForm(title = "t", listId = 1, due = due, isAllDay = true)
assertThat(switched(listOf(TaskReminder(20 * 60)), form)).containsExactly(TaskReminder(1_440))
}
@Test
fun `a time of day is dropped when the task turns timed`() {
val form = TaskForm(title = "t", listId = 1, due = now + 2.days + 3.hours)
val ownTime = TaskReminder.allDay(0, 17 * 60)
assertThat(switched(listOf(ownTime, TaskReminder(2_880)), form)).containsExactly(TaskReminder(2_880))
assertThat(switched(listOf(ownTime), form)).containsExactly(dayBeforeDue, hourBeforeDue)
}
}
@@ -0,0 +1,311 @@
package de.jeanlucmakiola.agendula.data.tasks.ical
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.domain.allDayInstantOf
import de.jeanlucmakiola.agendula.domain.ical.ICalComponent
import de.jeanlucmakiola.agendula.domain.ical.ICalParser
import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import java.time.LocalDate
import kotlin.time.Duration.Companion.hours
import kotlin.time.Instant
class AlarmCodecTest {
private val due = Instant.parse("2026-10-01T15:00:00Z")
private val start = Instant.parse("2026-10-01T09:00:00Z")
private val timed = TaskEntity(listId = 1, uid = "t", due = due)
private val withStart = timed.copy(dtstart = start)
private val allDay = TaskEntity(
listId = 1,
uid = "t",
due = allDayInstantOf(LocalDate.of(2026, 10, 1)),
isAllDay = true,
)
private fun alarm(vararg lines: String): ICalComponent =
ICalParser.parse((listOf("BEGIN:VALARM") + lines + "END:VALARM").joinToString("\r\n"))
private fun lead(minutes: Int, reference: AlarmReference = AlarmReference.DUE) = AlarmCodec.Lead(minutes, reference)
@Nested
inner class Claiming {
@Test
fun `a display alarm before due is claimed`() {
assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER;RELATED=END:-PT15M"), timed))
.isEqualTo(lead(15))
}
@Test
fun `an audio alarm is claimed too, an email one is not`() {
assertThat(AlarmCodec.resolve(alarm("ACTION:AUDIO", "TRIGGER;RELATED=END:-PT5M"), timed)).isEqualTo(lead(5))
assertThat(AlarmCodec.resolve(alarm("ACTION:EMAIL", "TRIGGER;RELATED=END:-PT5M"), timed)).isNull()
assertThat(AlarmCodec.resolve(alarm("TRIGGER;RELATED=END:-PT5M"), timed)).isNull()
}
@Test
fun `alarms that are somebody else's to fire stay in the residue`() {
val unclaimable = listOf(
alarm("ACTION:DISPLAY", "X-APPLE-DEFAULT-ALARM:TRUE", "TRIGGER;RELATED=END:-PT15M"),
alarm("ACTION:DISPLAY", "X-APPLE-PROXIMITY:ARRIVE", "TRIGGER;VALUE=DATE-TIME:19760401T005545Z"),
alarm("ACTION:DISPLAY", "PROXIMITY:DEPART", "TRIGGER;RELATED=END:-PT15M"),
alarm("ACTION:DISPLAY", "REPEAT:2", "DURATION:PT5M", "TRIGGER;RELATED=END:-PT15M"),
alarm("ACTION:DISPLAY", "RELATED-TO;RELTYPE=SNOOZE:other", "TRIGGER;RELATED=END:-PT15M"),
alarm("ACTION:DISPLAY", "TRIGGER;VALUE=DATE-TIME:19760401T005545Z"),
)
unclaimable.forEach { assertThat(AlarmCodec.resolve(it, timed)).isNull() }
}
@Test
fun `a trigger after its anchor is not claimed, because the planner would fire it early`() {
assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER;RELATED=END:PT10M"), timed)).isNull()
}
@Test
fun `a lead in seconds is not one the planner can reproduce`() {
assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER;RELATED=END:-PT90S"), timed)).isNull()
}
@Test
fun `RELATED=END without a DUE has nothing to count from`() {
val noDue = timed.copy(due = null, dtstart = start)
assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER;RELATED=END:-PT15M"), noDue)).isNull()
}
@Test
fun `an absolute UTC trigger becomes a lead before due`() {
assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER;VALUE=DATE-TIME:20261001T143000Z"), timed))
.isEqualTo(lead(30))
}
@Test
fun `an absolute trigger on a recurring task has no single lead`() {
val recurring = timed.copy(rrule = "FREQ=DAILY")
assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER;VALUE=DATE-TIME:20261001T143000Z"), recurring))
.isNull()
}
@Test
fun `the identity is taken from X-WR-ALARMUID, then UID`() {
val apple = AlarmCodec.fromVAlarm(alarm("ACTION:DISPLAY", "X-WR-ALARMUID:apple", "UID:rfc", "TRIGGER:-PT5M"), timed)
val rfc = AlarmCodec.fromVAlarm(alarm("ACTION:DISPLAY", "UID:rfc", "TRIGGER:-PT5M"), timed)
assertThat(apple?.uid).isEqualTo("apple")
assertThat(rfc?.uid).isEqualTo("rfc")
}
}
@Nested
inner class Anchors {
@Test
fun `RELATED=START with a distinct DTSTART is a before-start reminder`() {
assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER;RELATED=START:-PT10M"), withStart))
.isEqualTo(lead(10, AlarmReference.START))
}
@Test
fun `RELATED is START by default`() {
assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER:-PT10M"), withStart))
.isEqualTo(lead(10, AlarmReference.START))
}
@Test
fun `START on a task whose start is its due is a before-due reminder`() {
val same = timed.copy(dtstart = due)
assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER:-PT10M"), same)).isEqualTo(lead(10))
}
@Test
fun `START on a task with only a DUE is Apple's shape, and reads as before-due`() {
assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER;RELATED=START:-PT30M"), timed))
.isEqualTo(lead(30))
}
}
@Nested
inner class AllDay {
@Test
fun `a relative trigger counts back from the date's midnight`() {
assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER;RELATED=END:-P1D"), allDay))
.isEqualTo(lead(24 * 60))
// 09:00 the day before.
assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER;RELATED=END:-PT15H"), allDay))
.isEqualTo(lead(15 * 60))
}
@Test
fun `a time on the day itself is a time of day, not an after-due alarm`() {
assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER;RELATED=END:PT9H"), allDay))
.isEqualTo(lead(-9 * 60))
}
@Test
fun `a trigger after the day is over is not claimed`() {
assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER;RELATED=END:PT24H"), allDay)).isNull()
}
@Test
fun `an absolute trigger counts back from the due date`() {
// 09:00 Berlin the day before, as Apple writes it.
assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER;VALUE=DATE-TIME:20260930T070000Z"), allDay))
.isEqualTo(lead(24 * 60))
}
@Test
fun `an authored all-day alarm is written in days`() {
val row = TaskAlarmEntity(taskId = 1, minutesBefore = 2 * 24 * 60, uid = "a")
assertThat(AlarmCodec.toVAlarm(row, allDay, due)?.property("TRIGGER")?.value).isEqualTo("-P2D")
val onTheDay = row.copy(minutesBefore = 0)
assertThat(AlarmCodec.toVAlarm(onTheDay, allDay, due)?.property("TRIGGER")?.value).isEqualTo("PT0S")
}
@Test
fun `an authored all-day alarm with a time of its own is written from midnight`() {
val nine = TaskAlarmEntity(taskId = 1, minutesBefore = -9 * 60, uid = "a")
assertThat(AlarmCodec.toVAlarm(nine, allDay, due)?.property("TRIGGER")?.value).isEqualTo("PT9H")
val fivePmBefore = nine.copy(minutesBefore = 7 * 60)
assertThat(AlarmCodec.toVAlarm(fivePmBefore, allDay, due)?.property("TRIGGER")?.value).isEqualTo("-PT7H")
}
}
@Nested
inner class Writing {
private val earlier = due - 48.hours
@Test
fun `an authored alarm names itself both ways and counts back from due`() {
val written = AlarmCodec.toVAlarm(TaskAlarmEntity(taskId = 1, minutesBefore = 15, uid = "a-1"), timed, earlier)!!
assertThat(ICalSerializer.serialize(written)).isEqualTo(
"BEGIN:VALARM\r\n" +
"UID:a-1\r\n" +
"X-WR-ALARMUID:a-1\r\n" +
"ACTION:DISPLAY\r\n" +
"DESCRIPTION:Reminder\r\n" +
"TRIGGER;RELATED=END:-PT15M\r\n" +
"END:VALARM\r\n",
)
}
@Test
fun `a before-start row is written against START`() {
val row = TaskAlarmEntity(taskId = 1, minutesBefore = 10, reference = AlarmReference.START, uid = "a")
val trigger = AlarmCodec.toVAlarm(row, withStart, earlier)?.property("TRIGGER")
assertThat(trigger?.param("RELATED")).isEqualTo("START")
assertThat(trigger?.value).isEqualTo("-PT10M")
}
@Test
fun `a row whose anchor is gone is not written, so the resource stays valid`() {
val row = TaskAlarmEntity(taskId = 1, minutesBefore = 15, uid = "a")
assertThat(AlarmCodec.toVAlarm(row, timed.copy(due = null), earlier)).isNull()
}
@Test
fun `an alarm long past is written acknowledged`() {
val row = TaskAlarmEntity(taskId = 1, minutesBefore = 15, uid = "a")
val muchLater = due + 7.hours
assertThat(AlarmCodec.toVAlarm(row, timed, muchLater)?.property("ACKNOWLEDGED")?.value)
.isEqualTo("20261001T220000Z")
assertThat(AlarmCodec.toVAlarm(row, timed, earlier)?.property("ACKNOWLEDGED")).isNull()
}
@Test
fun `one still inside the missed-reminder grace is not, or it would silence the late reminder here`() {
val row = TaskAlarmEntity(taskId = 1, minutesBefore = 15, uid = "a")
assertThat(AlarmCodec.toVAlarm(row, timed, due + 1.hours)?.property("ACKNOWLEDGED")).isNull()
}
@Test
fun `a before-start row on a task that lost its start goes out before due`() {
val row = TaskAlarmEntity(taskId = 1, minutesBefore = 10, reference = AlarmReference.START, uid = "a")
val trigger = AlarmCodec.toVAlarm(row, timed, earlier)?.property("TRIGGER")
assertThat(trigger?.param("RELATED")).isEqualTo("END")
assertThat(trigger?.value).isEqualTo("-PT10M")
}
@Test
fun `a before-due row on a task without a due goes out before start`() {
val row = TaskAlarmEntity(taskId = 1, minutesBefore = 10, uid = "a")
val trigger = AlarmCodec.toVAlarm(row, withStart.copy(due = null), earlier)?.property("TRIGGER")
assertThat(trigger?.param("RELATED")).isEqualTo("START")
assertThat(trigger?.value).isEqualTo("-PT10M")
}
@Test
fun `an all-day row kept in whole days by an earlier build keeps the original's time`() {
val row = TaskAlarmEntity(
taskId = 1,
minutesBefore = 0,
uid = "a",
raw = "BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER;RELATED=END:PT9H\r\nEND:VALARM\r\n",
)
assertThat(AlarmCodec.toVAlarm(row, allDay, earlier)?.property("TRIGGER")?.value).isEqualTo("PT9H")
}
@Test
fun `the wire lead is what the row reads back as`() {
val afterDue = TaskAlarmEntity(taskId = 1, minutesBefore = -15, uid = "a")
assertThat(AlarmCodec.wireLead(afterDue, timed)).isEqualTo(lead(0))
assertThat(AlarmCodec.wireLead(afterDue, allDay)).isEqualTo(lead(-15))
}
@Test
fun `a row rounded by an earlier build does not claim the original's time as its own`() {
val row = TaskAlarmEntity(
taskId = 1,
minutesBefore = 0,
uid = "a",
raw = "BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER;RELATED=END:PT9H\r\nEND:VALARM\r\n",
)
assertThat(AlarmCodec.wireLead(row, allDay)).isEqualTo(lead(0))
}
}
@Nested
inner class Unchanged {
private fun claimed(text: String, task: TaskEntity) =
checkNotNull(AlarmCodec.fromVAlarm(alarm(*text.split("|").toTypedArray()), task))
@Test
fun `an unchanged row is written back verbatim, whatever spelling it arrived in`() {
val row = claimed("ACTION:DISPLAY|TRIGGER;RELATED=END:-P0DT0H15M0S|X-VENDOR:keep", timed)
assertThat(row.minutesBefore).isEqualTo(15)
val written = AlarmCodec.toVAlarm(row, timed, due)!!
assertThat(written.property("TRIGGER")?.value).isEqualTo("-P0DT0H15M0S")
assertThat(written.property("X-VENDOR")?.value).isEqualTo("keep")
}
@Test
fun `a relative original survives a due-date edit`() {
val row = claimed("ACTION:DISPLAY|TRIGGER;RELATED=END:-PT15M|X-VENDOR:keep", timed)
val moved = timed.copy(due = due + 24.hours)
assertThat(AlarmCodec.toVAlarm(row, moved, due)?.property("X-VENDOR")).isNotNull()
}
@Test
fun `an absolute original stops matching once due moves, and the row is authored fresh`() {
val row = claimed("ACTION:DISPLAY|TRIGGER;VALUE=DATE-TIME:20261001T143000Z|X-WR-ALARMUID:abs", timed)
val moved = timed.copy(due = due + 24.hours)
val written = AlarmCodec.toVAlarm(row, moved, due)!!
assertThat(written.property("TRIGGER")?.value).isEqualTo("-PT30M")
// Under its old identity, so other clients see it change, not appear.
assertThat(written.property("X-WR-ALARMUID")?.value).isEqualTo("abs")
}
@Test
fun `an edited lead is authored fresh`() {
val row = claimed("ACTION:DISPLAY|TRIGGER;RELATED=END:-PT15M|X-VENDOR:keep", timed).copy(minutesBefore = 60)
val written = AlarmCodec.toVAlarm(row, timed, due)!!
assertThat(written.property("TRIGGER")?.value).isEqualTo("-PT1H")
assertThat(written.property("X-VENDOR")).isNull()
}
}
}
@@ -1,6 +1,8 @@
package de.jeanlucmakiola.agendula.data.tasks.ical
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
import de.jeanlucmakiola.agendula.domain.TaskStatus
@@ -8,6 +10,7 @@ import de.jeanlucmakiola.agendula.domain.ical.ICalComponent
import de.jeanlucmakiola.agendula.domain.ical.ICalParser
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import kotlin.time.Duration.Companion.days
import kotlin.time.Instant
class VTodoMapperTest {
@@ -442,4 +445,64 @@ class VTodoMapperTest {
timezone = timezone,
unknownProperties = unknownProperties,
)
@Nested
inner class Alarms {
private val due = Instant.parse("2026-10-01T15:00:00Z")
private val task = TaskEntity(listId = 1, uid = "t-1", title = "Pay rent", due = due)
@Test
fun `claimed alarms leave the residue and unclaimed ones stay`() {
val mapped = VTodoMapper.read(
vtodo(
"DUE:20261001T150000Z",
"BEGIN:VALARM", "ACTION:DISPLAY", "TRIGGER;RELATED=END:-PT15M", "END:VALARM",
"BEGIN:VALARM", "ACTION:EMAIL", "TRIGGER;RELATED=END:-PT1H", "END:VALARM",
),
)
assertThat(mapped.alarms.map { it.minutesBefore }).containsExactly(15)
assertThat(mapped.entity.unknownProperties).contains("ACTION:EMAIL")
assertThat(mapped.entity.unknownProperties).doesNotContain("ACTION:DISPLAY")
}
@Test
fun `the task's reminders are written ahead of the residue's alarms`() {
val withResidue = task.copy(
unknownProperties = "BEGIN:VALARM\r\nACTION:EMAIL\r\nTRIGGER;RELATED=END:-PT1H\r\nEND:VALARM\r\n",
)
val written = VTodoMapper.write(
withResidue,
now = due - 1.days,
alarms = listOf(TaskAlarmEntity(taskId = 1, minutesBefore = 15, uid = "a-1")),
)
val alarms = written.components("VALARM")
assertThat(alarms.map { it.property("ACTION")?.value }).containsExactly("DISPLAY", "EMAIL").inOrder()
assertThat(alarms.first().property("TRIGGER")?.value).isEqualTo("-PT15M")
}
@Test
fun `a reminder left behind by a cleared due date does not make the task unuploadable`() {
val cleared = task.copy(due = null)
val written = VTodoMapper.write(
cleared,
alarms = listOf(TaskAlarmEntity(taskId = 1, minutesBefore = 15, uid = "a-1")),
)
assertThat(written.components("VALARM")).isEmpty()
assertThat(VTodoMapper.validate(written)).isEmpty()
}
@Test
fun `a reminder survives write and read with its identity`() {
val written = VTodoMapper.write(
task,
now = due - 1.days,
alarms = listOf(TaskAlarmEntity(taskId = 1, minutesBefore = 30, uid = "a-1")),
)
val back = VTodoMapper.read(written).alarms.single()
assertThat(back.minutesBefore).isEqualTo(30)
assertThat(back.reference).isEqualTo(AlarmReference.DUE)
assertThat(back.uid).isEqualTo("a-1")
}
}
}
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.agendula.data.tasks.ical
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
import de.jeanlucmakiola.agendula.domain.ical.ICalComponent
import de.jeanlucmakiola.agendula.domain.ical.ICalParser
import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer
@@ -23,6 +24,7 @@ class VTodoRoundTripTest {
"nextcloud-shared-confidential",
"recurring-master-overrides",
"valarm-unknown-nested",
"valarm-claimed-apple",
"unknown-component",
"unknown-tzid",
"uid-special-chars",
@@ -70,6 +72,29 @@ class VTodoRoundTripTest {
assertThat(alarm.property("ACKNOWLEDGED")?.value).isEqualTo("20260901T113000Z")
}
@Test
fun `a claimed alarm becomes a reminder and comes back exactly as it arrived`() {
val original = vtodosOf("valarm-claimed-apple").single()
val mapped = VTodoMapper.read(original)
val alarm = mapped.alarms.single()
// RELATED=START with only a DUE: Apple's shape, read as before-due.
assertThat(alarm.minutesBefore).isEqualTo(30)
assertThat(alarm.reference).isEqualTo(AlarmReference.DUE)
assertThat(alarm.uid).isEqualTo("8E6C4A1E-0000-4C1B-9B1A-3F5F4C2D9A11")
assertThat(mapped.entity.unknownProperties.orEmpty()).doesNotContain("VALARM")
val written = roundTrip(original).components("VALARM").single()
assertThat(written).isEqualTo(original.components("VALARM").single())
}
@Test
fun `an Apple default alarm is left in the residue`() {
val mapped = VTodoMapper.read(vtodosOf("valarm-unknown-nested").single())
assertThat(mapped.alarms).isEmpty()
assertThat(mapped.entity.unknownProperties).contains("X-APPLE-DEFAULT-ALARM:TRUE")
}
@Test
fun `an entirely unknown sub-component survives`() {
val roundTripped = roundTrip(vtodosOf("unknown-component").single())
@@ -145,6 +170,7 @@ class VTodoRoundTripTest {
entity = mapped.entity,
parentUid = mapped.parentUid,
now = Instant.fromEpochMilliseconds(0),
alarms = mapped.alarms,
)
// Serialise and re-parse rather than comparing the tree directly: folding
// and parameter quoting are where a serialiser loses data, and skipping
@@ -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()
}
}
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.agendula.data.tasks.room
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.domain.TaskStatus
import org.junit.jupiter.api.Test
import kotlin.time.Instant
@@ -53,6 +54,17 @@ class TaskFormWriterTest {
assertThat(entity.isDirty).isTrue()
}
@Test
fun `an empty set the editor chose turns reminders off, any other keeps the flag`() {
val due = Instant.parse("2026-02-01T10:00:00Z")
val chosen = form(due = due).copy(remindersChosen = true)
assertThat(TaskFormWriter.apply(task(), chosen, NOW, ZONE).remindersOff).isTrue()
val withOne = chosen.copy(reminders = listOf(TaskReminder(15)))
assertThat(TaskFormWriter.apply(task().copy(remindersOff = true), withOne, NOW, ZONE).remindersOff).isFalse()
assertThat(TaskFormWriter.apply(task().copy(remindersOff = true), form(due = due), NOW, ZONE).remindersOff).isTrue()
assertThat(TaskFormWriter.apply(task(), form(due = due), NOW, ZONE).remindersOff).isFalse()
}
@Test
fun `tags are trimmed and blanks dropped`() {
val entity = TaskFormWriter.apply(task(), form(tags = listOf(" Work ", "", " ", "Home")), NOW, ZONE)
@@ -1,6 +1,8 @@
package de.jeanlucmakiola.agendula.data.tasks.transfer
import com.google.common.truth.Truth.assertThat
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 org.junit.jupiter.api.Test
import java.io.ByteArrayOutputStream
@@ -24,12 +26,20 @@ class IcsImportTest {
override fun hasUid(listId: Long, uid: String) = rows.any { it.listId == listId && it.uid == uid }
override fun hasUidAnywhere(uid: String) = rows.any { it.uid == uid }
override fun insert(row: TaskEntity): Long {
val id = nextId++
rows += row.copy(id = id)
return id
}
val alarms = mutableListOf<TaskAlarmEntity>()
override fun insertAlarms(taskId: Long, alarms: List<TaskAlarmEntity>) {
this.alarms += alarms.map { it.copy(taskId = taskId) }
}
override fun masterByUid(listId: Long, uid: String) =
rows.firstOrNull { it.listId == listId && it.uid == uid && it.recurrenceId == null }
@@ -87,12 +97,55 @@ class IcsImportTest {
val child = rows.single { it.recurrenceId != null }
assertThat(master.rrule).isEqualTo("FREQ=WEEKLY;BYDAY=MO,TH")
assertThat(master.unknownProperties).contains("X-APPLE-SORT-ORDER:7")
assertThat(master.unknownProperties).contains("BEGIN:VALARM")
// The alarm is a reminder now, carrying the original for the upload.
assertThat(master.unknownProperties).doesNotContain("BEGIN:VALARM")
val alarm = store.alarms.single()
assertThat(alarm.taskId).isEqualTo(master.id)
assertThat(alarm.minutesBefore).isEqualTo(15)
assertThat(alarm.reference).isEqualTo(AlarmReference.START)
assertThat(alarm.raw).contains("DESCRIPTION:Water")
assertThat(child.masterId).isEqualTo(master.id)
assertThat(child.title).isEqualTo("Water plants (balcony)")
assertThat(rows.all { it.listId == 1L && it.isDirty && it.href == null }).isTrue()
}
@Test
fun `an override keeps its own alarms and an Apple default alarm stays in the residue`() {
val withAlarm = vtodo(
"series", "Water plants (balcony)",
"RECURRENCE-ID:20260604T080000Z",
"DTSTART:20260604T100000Z",
"DUE:20260604T110000Z",
"BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER;RELATED=END:-PT1H\r\nEND:VALARM",
"BEGIN:VALARM\r\nACTION:DISPLAY\r\nX-APPLE-DEFAULT-ALARM:TRUE\r\nTRIGGER;RELATED=END:-PT5M\r\nEND:VALARM",
)
val parsed = IcsImport.parse(listOf(IcsSource("plants.ics", calendar(series, withAlarm))))
val store = FakeImportStore()
IcsImport.write(parsed, ImportTarget.Existing(1L), store)
val child = store.byUid("series").single { it.recurrenceId != null }
val own = store.alarms.filter { it.taskId == child.id }
assertThat(own.map { it.minutesBefore to it.reference }).containsExactly(60 to AlarmReference.DUE)
assertThat(child.unknownProperties).contains("X-APPLE-DEFAULT-ALARM:TRUE")
}
@Test
fun `the same file in a second list gives the copy alarm identities of its own`() {
val parsed = IcsImport.parse(listOf(IcsSource("plants.ics", calendar(series))))
val store = FakeImportStore()
IcsImport.write(parsed, ImportTarget.Existing(1L), store)
IcsImport.write(parsed, ImportTarget.Existing(2L), store)
val (first, second) = store.alarms
assertThat(first.raw).isNotNull()
assertThat(second.uid).isNotEqualTo(first.uid)
// Fresh, so an untouched copy is not re-sent as the original text either.
assertThat(second.raw).isNull()
assertThat(second.minutesBefore).isEqualTo(first.minutesBefore)
}
@Test
fun `a parent later in the file is still linked`() {
val child = vtodo("child", "Buy soil", "RELATED-TO;RELTYPE=PARENT:parent")
@@ -31,9 +31,15 @@ class TaskFormTest {
}
@Test
fun `a reminder requires a due date`() {
val errors = TaskForm(title = "x", listId = 1, reminders = listOf(10)).validate()
assertThat(errors).contains(TaskFormError.REMINDER_WITHOUT_DUE)
fun `a reminder requires a start or due date`() {
val errors = TaskForm(title = "x", listId = 1, reminders = listOf(TaskReminder(10))).validate()
assertThat(errors).contains(TaskFormError.REMINDER_WITHOUT_DATE)
}
@Test
fun `a start alone is enough for a reminder`() {
val errors = TaskForm(title = "x", listId = 1, start = Instant.fromEpochSeconds(0), reminders = listOf(TaskReminder(10))).validate()
assertThat(errors).doesNotContain(TaskFormError.REMINDER_WITHOUT_DATE)
}
@Test
@@ -0,0 +1,60 @@
package de.jeanlucmakiola.agendula.domain
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class TaskReminderTest {
@Test
fun `whole days keep the device's all-day time`() {
assertThat(TaskReminder(0).allDay).isEqualTo(AllDayReminder(0, null))
assertThat(TaskReminder(2 * 1_440).allDay).isEqualTo(AllDayReminder(2, null))
}
@Test
fun `anything else names a time of day`() {
assertThat(TaskReminder(-9 * 60).allDay).isEqualTo(AllDayReminder(0, 9 * 60))
assertThat(TaskReminder(7 * 60).allDay).isEqualTo(AllDayReminder(1, 17 * 60))
assertThat(TaskReminder(15).allDay).isEqualTo(AllDayReminder(1, 23 * 60 + 45))
}
@Test
fun `a day and a time round-trip`() {
listOf(0 to 9 * 60, 1 to 17 * 60, 7 to 30, 3 to null).forEach { (days, minute) ->
assertThat(TaskReminder.allDay(days, minute).allDay).isEqualTo(AllDayReminder(days, minute))
}
}
@Test
fun `a lead past the day is clamped to its last minute`() {
assertThat(TaskReminder(-1_440).allDay).isEqualTo(AllDayReminder(0, 1_439))
}
@Test
fun `midnight is a minute past it`() {
assertThat(TaskReminder.allDay(1, 0).allDay).isEqualTo(AllDayReminder(1, 1))
assertThat(TaskReminder.allDay(1, null).allDay).isEqualTo(AllDayReminder(1, null))
}
@Test
fun `a default lead keeps its anchor whatever its lead`() {
val reminders = listOf(TaskReminder(-540, fromStart = true), TaskReminder(1_440, fromStart = true), TaskReminder(-540))
assertThat(reminders.map { it.asReminderLead().asTaskReminder() }).isEqualTo(reminders)
}
@Test
fun `whole days round to the nearest`() {
assertThat(TaskReminder(60).inWholeDays()).isEqualTo(TaskReminder(0))
assertThat(TaskReminder(23 * 60, fromStart = true).inWholeDays()).isEqualTo(TaskReminder(1_440, fromStart = true))
assertThat(TaskReminder(-540).inWholeDays()).isEqualTo(TaskReminder(0))
}
@Test
fun `all-day reminders are listed in the order they ring`() {
val eightPm = TaskReminder(0)
val nine = TaskReminder.allDay(0, 9 * 60)
val dayBefore = TaskReminder(1_440)
assertThat(listOf(eightPm, nine, dayBefore).sortedWith(taskReminderDisplayOrder(true, 20 * 60)))
.containsExactly(dayBefore, nine, eightPm).inOrder()
}
}
@@ -14,6 +14,8 @@ fun testTask(
completedAt: Instant? = null,
parentId: Long? = null,
tags: List<String> = emptyList(),
start: Instant? = null,
created: Instant? = null,
): Task = Task(
taskId = id,
listId = listId,
@@ -24,7 +26,7 @@ fun testTask(
priority = priority,
status = status,
percentComplete = null,
start = null,
start = start,
due = due,
isAllDay = isAllDay,
timeZone = null,
@@ -37,7 +39,7 @@ fun testTask(
isRecurring = false,
occurrenceStart = null,
distanceFromCurrent = 0,
created = null,
created = created,
lastModified = null,
tags = tags,
)
@@ -0,0 +1,36 @@
package de.jeanlucmakiola.agendula.domain.ical
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
/** RFC 5545 §3.3.6 `dur-value`, as alarm triggers use it. */
class ICalDurationTest {
@Test
fun `the forms other clients write`() {
assertThat(ICalValues.parseDuration("-PT15M")).isEqualTo(-900L)
assertThat(ICalValues.parseDuration("+PT5M")).isEqualTo(300L)
assertThat(ICalValues.parseDuration("PT0S")).isEqualTo(0L)
assertThat(ICalValues.parseDuration("P1W")).isEqualTo(7 * 86_400L)
assertThat(ICalValues.parseDuration("-P1DT2H")).isEqualTo(-(86_400L + 7_200L))
assertThat(ICalValues.parseDuration("-P0DT0H15M0S")).isEqualTo(-900L)
assertThat(ICalValues.parseDuration("-PT90S")).isEqualTo(-90L)
}
@Test
fun `what is not a duration is null`() {
listOf("", "P", "PT", "-P1DT", "15M", "PT15", "P1H", "20261001T090000Z", "P99999999999999999999D")
.forEach { assertThat(ICalValues.parseDuration(it)).isNull() }
}
@Test
fun `written in days and time, and read back the same`() {
assertThat(ICalValues.formatDuration(-900)).isEqualTo("-PT15M")
assertThat(ICalValues.formatDuration(0)).isEqualTo("PT0S")
assertThat(ICalValues.formatDuration(-86_400)).isEqualTo("-P1D")
assertThat(ICalValues.formatDuration(-(86_400L + 5_400L))).isEqualTo("-P1DT1H30M")
listOf(-900L, 0L, -86_400L, -93_784L, 3_600L).forEach {
assertThat(ICalValues.parseDuration(ICalValues.formatDuration(it))).isEqualTo(it)
}
}
}
+2 -1
View File
@@ -13,7 +13,8 @@ VTIMEZONE bodies, fold positions, parameter quoting).
|---|---|
| `nextcloud-shared-confidential.ics` | A `CLASS:CONFIDENTIAL` task as Nextcloud rewrites it on GET from a **shared** calendar — `DUE`, `STATUS`, `COMPLETED`, `PERCENT-COMPLETE`, `PRIORITY` and `RELATED-TO` already stripped, ETag left untouched. Re-PUTting this destroys the owner's task |
| `recurring-master-overrides.ics` | Master plus two `RECURRENCE-ID` overrides in one resource, sharing a UID |
| `valarm-unknown-nested.ics` | Unknown properties **inside** a `VALARM` — the case a flat property-per-row model cannot represent |
| `valarm-unknown-nested.ics` | Unknown properties **inside** a `VALARM` — the case a flat property-per-row model cannot represent. An Apple *default* alarm, so it stays in the residue |
| `valarm-claimed-apple.ics` | The same Apple alarm without `X-APPLE-DEFAULT-ALARM`, so it is **claimed** as a reminder: `RELATED=START` on a task with only `DUE` (Apple's non-conformant shape) reads as a before-due lead, and the original must come back verbatim through the row's `raw` |
| `unknown-component.ics` | An entirely unknown sub-component |
| `unknown-tzid.ics` | A `TZID` no device tzdb knows, with its own `VTIMEZONE` |
| `uid-special-chars.ics` | A UID containing `/` and `@` — the href sanitiser must not assume it is filename-safe |
@@ -0,0 +1,17 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Apple Inc.//iOS 18.2//EN
BEGIN:VTODO
UID:alarm-claimed-001
DTSTAMP:20260901T120000Z
SUMMARY:Renew passport
DUE;TZID=Europe/Berlin:20261001T170000
BEGIN:VALARM
ACTION:DISPLAY
DESCRIPTION:Reminder
TRIGGER;RELATED=START:-PT30M
ACKNOWLEDGED:20260901T113000Z
X-WR-ALARMUID:8E6C4A1E-0000-4C1B-9B1A-3F5F4C2D9A11
END:VALARM
END:VTODO
END:VCALENDAR
@@ -1,6 +1,8 @@
package de.jeanlucmakiola.agendula.data.sync
import com.google.common.truth.Truth.assertThat
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.TaskMove
@@ -970,6 +972,150 @@ class CollectionSyncerTest {
assertThat(report.discardedEdits.single().cause).isEqualTo(DiscardedEdit.Cause.DELETE_LOST)
}
// ------------------------------------------------------------- reminders
private val due = Instant.parse("2026-03-05T09:00:00Z")
private val alarm15 = "BEGIN:VALARM\r\nACTION:DISPLAY\r\nX-WR-ALARMUID:srv-1\r\n" +
"TRIGGER;RELATED=END:-PT15M\r\nEND:VALARM"
private fun alarm(taskId: Long, minutes: Int, uid: String = "a-$minutes") =
TaskAlarmEntity(taskId = taskId, minutesBefore = minutes, uid = uid)
@Test fun `an upload carries the task's reminders as VALARMs`() {
store.rows += task(id = 1, uid = "a", title = "Pay rent").copy(due = due, isDirty = true)
store.alarms += alarm(1, 15, uid = "a-1")
sync()
val body = remote.resources.values.single().body
assertThat(body).contains("BEGIN:VALARM")
assertThat(body).contains("TRIGGER;RELATED=END:-PT15M")
assertThat(body).contains("X-WR-ALARMUID:a-1")
}
@Test fun `the alarm keeps its identity across uploads`() {
store.rows += task(id = 1, uid = "a", title = "Pay rent").copy(due = due, isDirty = true)
store.alarms += alarm(1, 15, uid = "a-1")
sync()
val first = remote.resources.values.single().body
store.rows[0] = store.rows[0].copy(title = "Pay the rent", isDirty = true)
sync()
val second = remote.resources.values.single().body
assertThat(second).isNotEqualTo(first)
assertThat(second).contains("X-WR-ALARMUID:a-1")
}
@Test fun `a downloaded alarm becomes a reminder and leaves the residue`() {
remote.put("one.ics", vtodo("a", "Pay rent", extra = "DUE:20260305T090000Z\r\n$alarm15"))
sync()
val row = store.rows.single()
val reminder = store.alarms.single()
assertThat(reminder.taskId).isEqualTo(row.id)
assertThat(reminder.minutesBefore).isEqualTo(15)
assertThat(reminder.uid).isEqualTo("srv-1")
assertThat(row.unknownProperties.orEmpty()).doesNotContain("VALARM")
assertThat(row.isDirty).isFalse()
}
@Test fun `a re-download keeps the reminder's row, and a removal on the server clears it`() {
remote.put("one.ics", vtodo("a", "Pay rent", extra = "DUE:20260305T090000Z\r\n$alarm15"))
sync()
val id = store.alarms.single().id
remote.put("one.ics", vtodo("a", "Pay the rent", extra = "DUE:20260305T090000Z\r\n$alarm15"), eTag = "e-2")
sync()
// Matched by identity, so the row is updated in place rather than churned.
assertThat(store.alarms.single().id).isEqualTo(id)
remote.put("one.ics", vtodo("a", "Pay the rent", extra = "DUE:20260305T090000Z"), eTag = "e-3")
sync()
assertThat(store.alarms).isEmpty()
// Turned off elsewhere: "none", so the device default does not take over.
assertThat(store.rows.single().remindersOff).isTrue()
}
@Test fun `a task that never had alarms still takes the default`() {
remote.put("one.ics", vtodo("a", "Pay rent", extra = "DUE:20260305T090000Z"))
sync()
assertThat(store.rows.single().remindersOff).isFalse()
}
@Test fun `an all-day reminder at a time of its own round-trips from midnight`() {
// 09:00 on the day.
store.rows += task(id = 1, uid = "a", title = "Pay rent")
.copy(due = Instant.parse("2026-03-05T00:00:00Z"), isAllDay = true, isDirty = true)
store.alarms += alarm(1, -9 * 60, uid = "a-1")
sync()
val sent = remote.resources.values.single()
assertThat(sent.body).contains("TRIGGER;RELATED=END:PT9H")
sent.eTag = de.jeanlucmakiola.caldav.ETag("e-2", weak = false)
sync()
assertThat(store.alarms.single().minutesBefore).isEqualTo(-9 * 60)
}
@Test fun `an alarm coming back as we sent it keeps the lead the wire could not carry`() {
// A lead past due is clamped to "at due" on the way out.
store.rows += task(id = 1, uid = "a", title = "Pay rent").copy(due = due, isDirty = true)
store.alarms += alarm(1, -15, uid = "a-1")
sync()
val sent = remote.resources.values.single()
assertThat(sent.body).contains("TRIGGER;RELATED=END:PT0S")
sent.eTag = de.jeanlucmakiola.caldav.ETag("e-2", weak = false)
sync()
assertThat(store.alarms.single().minutesBefore).isEqualTo(-15)
}
@Test fun `a before-start reminder on a task without a start survives the round trip`() {
store.rows += task(id = 1, uid = "a", title = "Pay rent").copy(due = due, isDirty = true)
store.alarms += alarm(1, 10, uid = "s-1").copy(reference = AlarmReference.START)
sync()
val sent = remote.resources.values.single()
// Sent from DUE, which is what the planner fires it from.
assertThat(sent.body).contains("TRIGGER;RELATED=END:-PT10M")
sent.eTag = de.jeanlucmakiola.caldav.ETag("e-2", weak = false)
sync()
assertThat(store.alarms.single().reference).isEqualTo(AlarmReference.START)
}
@Test fun `an override without alarms has none, whatever its master carries`() {
val body = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n" +
"BEGIN:VTODO\r\nUID:s\r\nSUMMARY:Water\r\nDUE:20260305T090000Z\r\nRRULE:FREQ=DAILY\r\n" +
"$alarm15\r\nEND:VTODO\r\n" +
"BEGIN:VTODO\r\nUID:s\r\nSUMMARY:Water (late)\r\nRECURRENCE-ID:20260306T090000Z\r\n" +
"DUE:20260306T120000Z\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"
remote.put("s.ics", body)
sync()
val master = store.rows.single { it.recurrenceId == null }
assertThat(store.alarms.map { it.taskId }).containsExactly(master.id)
}
@Test fun `a 412 discards a local reminder edit along with the rest`() {
remote.put("one.ics", vtodo("a", "Pay rent", extra = "DUE:20260305T090000Z\r\n$alarm15"), eTag = "newer")
store.rows += task(id = 1, uid = "a", title = "Pay rent")
.copy(href = "http://server/dav/tasks/one.ics", etag = "stale", due = due, isDirty = true)
store.alarms += alarm(1, 60)
sync()
assertThat(store.alarms.map { it.minutesBefore }).containsExactly(15)
}
private fun task(
id: Long,
uid: String,
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.agendula.data.sync
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.caldav.CalendarCollection
import de.jeanlucmakiola.caldav.ChangeSet
@@ -19,6 +20,10 @@ import okhttp3.HttpUrl.Companion.toHttpUrl
class FakeStore(rows: List<TaskEntity> = emptyList()) : SyncStore {
val rows = rows.toMutableList()
/** `task_alarms`, cascading with [rows] as the foreign key does. */
val alarms = mutableListOf<TaskAlarmEntity>()
private var nextAlarmId = 1L
val readOnlyWrites = mutableListOf<Pair<Long, Boolean>>()
/** In order, so a test can prove the token was stored *after* its bodies. */
@@ -27,7 +32,7 @@ class FakeStore(rows: List<TaskEntity> = emptyList()) : SyncStore {
override fun rowsIn(listId: Long) = this.rows.filter { it.listId == listId }
override fun insert(row: TaskEntity): Long {
fun insert(row: TaskEntity): Long {
// Computed against the rows as they stand, not at construction: tests
// seed `rows` directly after building this, and an id fixed up front
// collides with the first seeded row — which then makes a delete of one
@@ -45,6 +50,30 @@ class FakeStore(rows: List<TaskEntity> = emptyList()) : SyncStore {
override fun deleteAll(taskIds: List<Long>) {
rows.removeAll { it.id in taskIds }
alarms.removeAll { it.taskId in taskIds }
}
override fun alarmsFor(taskIds: List<Long>) = alarms.filter { it.taskId in taskIds }.groupBy { it.taskId }
override fun upsertWithAlarms(row: TaskEntity, alarms: List<TaskAlarmEntity>): Long {
if (row.id != 0L && rows.none { it.id == row.id }) return row.id
val id = if (row.id == 0L) insert(row) else row.id.also { update(row) }
val hadAny = this.alarms.any { it.taskId == id }
// Matched by uid, then lead, as `TaskAlarmDao.replaceFromServer` does.
val existing = this.alarms.filter { it.taskId == id }.toMutableList()
this.alarms.removeAll { it.taskId == id }
alarms.forEach { alarm ->
val match = existing.firstOrNull { it.uid == alarm.uid }
?: existing.firstOrNull { it.minutesBefore == alarm.minutesBefore && it.reference == alarm.reference }
match?.let(existing::remove)
this.alarms += alarm.copy(id = match?.id ?: nextAlarmId++, taskId = id)
}
val index = rows.indexOfFirst { it.id == id }
when {
alarms.isNotEmpty() -> rows[index] = rows[index].copy(remindersOff = false)
hadAny -> rows[index] = rows[index].copy(remindersOff = true)
}
return id
}
override fun markSynced(taskIds: List<Long>, href: String?, eTag: String?) {
+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.
+23 -8
View File
@@ -1,7 +1,7 @@
---
title: Privacy Policy — Agendula
description: What Agendula does with your data. No servers, no account, no analytics — your tasks stay on your device unless you add a CalDAV server yourself.
updated: 2026-09-21
updated: 2026-10-19
---
<!--
@@ -21,7 +21,7 @@ updated: 2026-09-21
render on the published page.
-->
**Last updated:** 21 September 2026
**Last updated:** 19 October 2026
Applies to the Android app **Agendula** (package `de.jeanlucmakiola.agendula`),
all versions and all distribution channels.
@@ -53,8 +53,10 @@ All of this is verifiable in the
## 3. Where your tasks live — your choice
- **On your device (the default)** — your task lists, tasks and reminders are
kept in Agendula's own database inside the app's private storage. Nothing is
published to other apps, and uninstalling the app removes it.
kept in Agendula's own database inside the app's private storage, and
uninstalling the app removes it. Another app can read these tasks only if you
grant it Agendula's "read Agendula tasks" permission, and change them only if
you grant it "add, change and delete Agendula tasks" (see section 9).
- **In a tasks provider you already use** — OpenTasks or tasks.org. Agendula
then reads and writes that app's task database through Android's provider
mechanism, after you grant its read/write permission. Whatever already
@@ -76,8 +78,9 @@ exported from the device.
### What is transmitted, and to whom
- The tasks in the synchronised lists, as standard iCalendar (`VTODO`) data,
and the credentials needed to authenticate.
- The tasks in the synchronised lists, as standard iCalendar (`VTODO`) data —
including their reminders, as `VALARM`s — and the credentials needed to
authenticate.
- Requests carry the user agent `Agendula (Android)` — a fixed string, so that
you can recognise and revoke the session on your server. No device identifier
is sent.
@@ -209,8 +212,20 @@ source:
used for scheduled background work (WorkManager); on older Android versions
it needs them to run an expedited sync.
Agendula publishes no content provider of its own and declares no permissions
that other apps could request.
Agendula declares two permissions that other apps can request. Android asks you
before granting either, and you can revoke them at any time in the system
settings.
- `de.jeanlucmakiola.agendula.permission.READ_TASKS`: an app that holds it can
read your task lists, tasks, reminders and tags from Agendula's own database.
- `de.jeanlucmakiola.agendula.permission.WRITE_TASKS`: an app that holds it can
add, change, complete and delete those tasks, reminders and tags. It cannot
change or delete task lists. Its changes sync to your CalDAV server like your
own edits.
Neither gives access to anything while Agendula works on top of an external
tasks provider. What an app does with your tasks is governed by its own privacy
policy.
## 10. Distribution channels
+1 -1
View File
@@ -55,7 +55,7 @@ xpp3 = "1.1.6"
# below API 29's DnsResolver (which is callback-only and cannot do TXT paths),
# and JNDI's DNS provider does not exist on Android at all. dnsjava is what DAVx5
# uses. BSD-3 — it goes on the attribution screen with the rest.
dnsjava = "3.6.5"
dnsjava = "3.6.3"
# WebDAV-Push delivery. The connector only: DAVx5 also ships the embedded FCM
# distributor, which pulls in Play Services and would get us flagged on F-Droid.
unifiedpush = "3.3.5"