Compare commits

..
Author SHA1 Message Date
makiolaj b3189f04b2 Keep sub-task resets apart from tick copies, scope copy markers (#160) 2026-11-12 16:40:39 +01:00
makiolaj 6f4ae7e2ec Move dated sub-tasks with their routine, mark tick copies (#160) 2026-11-12 16:27:51 +01:00
makiolaj 6113e73333 Restore reset sub-tasks on untick, review fixes (#160) 2026-11-12 16:12:46 +01:00
makiolaj d3fae3e69c Reopen sub-tasks when a routine moves on (#160) 2026-11-12 15:57:55 +01:00
Jean-Luc Makiolaandmakiolaj 3e34c60577 Release 1.3.1 (#146)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 18s
Renovate / renovate (push) Successful in 2m30s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 21m13s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 1m28s
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
Bumps to 1.3.1 (1030199), dates the changelog and adds the What's New for all eight store locales.

- Ticking a repeating task moves the series on (#137, #144)
- Recurring tasks keep their rule with tasks.org and OpenTasks (#135, #142)
- Keep the list order with tasks.org and OpenTasks (#136, #143)

Closes #135, closes #136, closes #137

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/146
2026-11-09 20:02:41 +01:00
Jean-Luc Makiola caf4357cee Translations update from Weblate (#138)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 10s
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 2m21s
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/138
2026-11-09 19:15:30 +01:00
Weblate 97164feb39 Translated using Weblate (Arabic)
Currently translated at 87.5% (613 of 700 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/ar/
2026-11-08 01:42:41 +00:00
Sup99999 8a521d32f9 Translated using Weblate (Arabic)
Currently translated at 87.5% (613 of 700 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/ar/
2026-11-08 01:42:41 +00:00
Савелий b68582177b Translated using Weblate (Russian)
Currently translated at 5.8% (41 of 700 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/ru/
2026-11-07 15:27:54 +00:00
Sup99999 05f43d6f76 Translated using Weblate (Arabic)
Currently translated at 80.0% (560 of 700 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/ar/
2026-11-05 04:12:37 +00:00
Knee8811 7498ffb37e Translated using Weblate (Czech)
Currently translated at 39.3% (266 of 676 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/cs/
2026-11-04 22:18:19 +00:00
55 changed files with 5458 additions and 256 deletions

No files matched your search

+26
View File
@@ -7,6 +7,32 @@ All notable changes to this project are documented here. The format follows
## [Unreleased]
### Fixed
- Sub-tasks of a repeating task start over when ticking off its current
occurrence moves it on to its next date, as in tasks.org: completed and
half-done ones open again, and dated ones move along with the task. Before,
the next occurrence showed them already ticked. Unticking gives them back
their state and dates, and repeating sub-tasks keep their own schedule
(#160).
## [1.3.1] - 2026-11-09
### Changed
- Ticking off the current occurrence of a repeating task moves the series on
to its next date and keeps a completed copy, as tasks.org and Apple
Reminders do. Other apps and the web views of servers such as Infomaniak now
see it as done instead of still open. Unticking it moves the series back,
as long as the series hasn't moved on or changed its start since (#137).
### Fixed
- With tasks.org as the storage provider, recurring tasks are recognised as
recurring. Before, editing one removed its recurrence and ticking it off
closed the whole series. With tasks.org and OpenTasks alike, the editor now
shows the task's repeat rule instead of none, and an occurrence changed on
its own stays part of its series (#135).
- With tasks.org or OpenTasks as the storage provider, the order you give your
lists is kept instead of snapping back (#136).
## [1.3.0] - 2026-11-02
### Added
+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 = 1030099
versionName = "1.3.0"
versionCode = 1030199
versionName = "1.3.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -0,0 +1,768 @@
{
"formatVersion": 1,
"database": {
"version": 5,
"identityHash": "fcb14ff68d892c72fa7459204276abb6",
"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, `label` 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"
},
{
"fieldPath": "label",
"columnName": "label",
"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"
]
}
]
},
{
"tableName": "advanced_occurrences",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`copy_id` INTEGER NOT NULL, `master_id` INTEGER NOT NULL, `anchor` INTEGER NOT NULL, `advanced_to` INTEGER NOT NULL, `prior_dtstart` INTEGER, `prior_due` INTEGER, `prior_status` INTEGER NOT NULL, `prior_percent_complete` INTEGER, `prior_completed_at` INTEGER, `spent` INTEGER NOT NULL, `rule_after` TEXT, `dropped_exdates` TEXT NOT NULL DEFAULT '', `from_override` INTEGER NOT NULL, `undoable` INTEGER NOT NULL DEFAULT 1, `settled` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`copy_id`), FOREIGN KEY(`copy_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`master_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "copyId",
"columnName": "copy_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "masterId",
"columnName": "master_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "anchor",
"columnName": "anchor",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "advancedTo",
"columnName": "advanced_to",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "priorDtstart",
"columnName": "prior_dtstart",
"affinity": "INTEGER"
},
{
"fieldPath": "priorDue",
"columnName": "prior_due",
"affinity": "INTEGER"
},
{
"fieldPath": "priorStatus",
"columnName": "prior_status",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "priorPercentComplete",
"columnName": "prior_percent_complete",
"affinity": "INTEGER"
},
{
"fieldPath": "priorCompletedAt",
"columnName": "prior_completed_at",
"affinity": "INTEGER"
},
{
"fieldPath": "spent",
"columnName": "spent",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "ruleAfter",
"columnName": "rule_after",
"affinity": "TEXT"
},
{
"fieldPath": "droppedExdates",
"columnName": "dropped_exdates",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "fromOverride",
"columnName": "from_override",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "undoable",
"columnName": "undoable",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "1"
},
{
"fieldPath": "settled",
"columnName": "settled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"copy_id"
]
},
"indices": [
{
"name": "index_advanced_occurrences_master_id_anchor",
"unique": false,
"columnNames": [
"master_id",
"anchor"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_advanced_occurrences_master_id_anchor` ON `${TABLE_NAME}` (`master_id`, `anchor`)"
}
],
"foreignKeys": [
{
"table": "tasks",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"copy_id"
],
"referencedColumns": [
"id"
]
},
{
"table": "tasks",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"master_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "advanced_detached",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `copy_id` INTEGER NOT NULL, `anchor` INTEGER NOT NULL, `task_id` INTEGER, FOREIGN KEY(`copy_id`) REFERENCES `advanced_occurrences`(`copy_id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`task_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "copyId",
"columnName": "copy_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "anchor",
"columnName": "anchor",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "taskId",
"columnName": "task_id",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_advanced_detached_copy_id",
"unique": false,
"columnNames": [
"copy_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_advanced_detached_copy_id` ON `${TABLE_NAME}` (`copy_id`)"
},
{
"name": "index_advanced_detached_task_id",
"unique": false,
"columnNames": [
"task_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_advanced_detached_task_id` ON `${TABLE_NAME}` (`task_id`)"
}
],
"foreignKeys": [
{
"table": "advanced_occurrences",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"copy_id"
],
"referencedColumns": [
"copy_id"
]
},
{
"table": "tasks",
"onDelete": "SET NULL",
"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, 'fcb14ff68d892c72fa7459204276abb6')"
]
}
}
@@ -0,0 +1,775 @@
{
"formatVersion": 1,
"database": {
"version": 6,
"identityHash": "6c8058a22084f0af602bc2f23c949b3c",
"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, `label` 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"
},
{
"fieldPath": "label",
"columnName": "label",
"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"
]
}
]
},
{
"tableName": "advanced_occurrences",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`copy_id` INTEGER NOT NULL, `master_id` INTEGER NOT NULL, `anchor` INTEGER NOT NULL, `advanced_to` INTEGER NOT NULL, `prior_dtstart` INTEGER, `prior_due` INTEGER, `prior_status` INTEGER NOT NULL, `prior_percent_complete` INTEGER, `prior_completed_at` INTEGER, `spent` INTEGER NOT NULL, `rule_after` TEXT, `dropped_exdates` TEXT NOT NULL DEFAULT '', `from_override` INTEGER NOT NULL, `undoable` INTEGER NOT NULL DEFAULT 1, `settled` INTEGER NOT NULL DEFAULT 0, `reset_subtasks` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`copy_id`), FOREIGN KEY(`copy_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`master_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "copyId",
"columnName": "copy_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "masterId",
"columnName": "master_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "anchor",
"columnName": "anchor",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "advancedTo",
"columnName": "advanced_to",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "priorDtstart",
"columnName": "prior_dtstart",
"affinity": "INTEGER"
},
{
"fieldPath": "priorDue",
"columnName": "prior_due",
"affinity": "INTEGER"
},
{
"fieldPath": "priorStatus",
"columnName": "prior_status",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "priorPercentComplete",
"columnName": "prior_percent_complete",
"affinity": "INTEGER"
},
{
"fieldPath": "priorCompletedAt",
"columnName": "prior_completed_at",
"affinity": "INTEGER"
},
{
"fieldPath": "spent",
"columnName": "spent",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "ruleAfter",
"columnName": "rule_after",
"affinity": "TEXT"
},
{
"fieldPath": "droppedExdates",
"columnName": "dropped_exdates",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "fromOverride",
"columnName": "from_override",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "undoable",
"columnName": "undoable",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "1"
},
{
"fieldPath": "settled",
"columnName": "settled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "resetSubtasks",
"columnName": "reset_subtasks",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"copy_id"
]
},
"indices": [
{
"name": "index_advanced_occurrences_master_id_anchor",
"unique": false,
"columnNames": [
"master_id",
"anchor"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_advanced_occurrences_master_id_anchor` ON `${TABLE_NAME}` (`master_id`, `anchor`)"
}
],
"foreignKeys": [
{
"table": "tasks",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"copy_id"
],
"referencedColumns": [
"id"
]
},
{
"table": "tasks",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"master_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "advanced_detached",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `copy_id` INTEGER NOT NULL, `anchor` INTEGER NOT NULL, `task_id` INTEGER, FOREIGN KEY(`copy_id`) REFERENCES `advanced_occurrences`(`copy_id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`task_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "copyId",
"columnName": "copy_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "anchor",
"columnName": "anchor",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "taskId",
"columnName": "task_id",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_advanced_detached_copy_id",
"unique": false,
"columnNames": [
"copy_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_advanced_detached_copy_id` ON `${TABLE_NAME}` (`copy_id`)"
},
{
"name": "index_advanced_detached_task_id",
"unique": false,
"columnNames": [
"task_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_advanced_detached_task_id` ON `${TABLE_NAME}` (`task_id`)"
}
],
"foreignKeys": [
{
"table": "advanced_occurrences",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"copy_id"
],
"referencedColumns": [
"copy_id"
]
},
{
"table": "tasks",
"onDelete": "SET NULL",
"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, '6c8058a22084f0af602bc2f23c949b3c')"
]
}
}
@@ -0,0 +1,76 @@
package de.jeanlucmakiola.agendula.data.tasks
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import org.junit.After
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
* List order against a real external provider (#136). Skipped unless one is
* installed and its permissions are granted.
*/
@RunWith(AndroidJUnit4::class)
class ExternalProviderListOrderTest {
private val context = ApplicationProvider.getApplicationContext<Context>()
private val resolver = ProviderResolver(AndroidProviderEnvironment(context)).apply {
storageMode = StorageMode.EXTERNAL
}
private val source = AndroidTasksDataSource(context, resolver)
private val created = mutableListOf<Long>()
@Before
fun setUp() {
val provider = resolver.resolve()
assumeTrue("no external tasks provider, or not granted", provider != null && resolver.hasPermission(provider))
listOf("Order A", "Order B", "Order C").forEach { created += source.createLocalList(it, 0) }
}
@After
fun tearDown() = created.forEach { runCatching { source.deleteList(it) } }
private fun ours() = source.taskLists().map { it.id }.filter { it in created }
/** Waits until the provider has gone quiet, so a change seen afterwards is the test's own. */
private fun settle() {
var last = System.currentTimeMillis()
val observer = source.registerObserver { last = System.currentTimeMillis() }
while (System.currentTimeMillis() - last < 1_500) Thread.sleep(100)
observer.close()
}
@Test
fun aReorderIsKeptAndReported() {
val (a, b, c) = created
settle()
val changed = CountDownLatch(1)
val observer = source.registerObserver { changed.countDown() }
source.reorderLists(listOf(c, a, b))
assertThat(changed.await(5, TimeUnit.SECONDS)).isTrue()
observer.close()
assertThat(ours()).containsExactly(c, a, b).inOrder()
assertThat(AndroidTasksDataSource(context, resolver).taskLists().map { it.id }.filter { it in created })
.containsExactly(c, a, b).inOrder()
}
@Test
fun aDeletedListLeavesNoPositionBehind() {
val (a, b, c) = created
source.reorderLists(listOf(c, a, b))
source.deleteList(c)
created -= c
source.taskLists()
assertThat(source.storedListOrder().keys).doesNotContain(c)
}
}
@@ -0,0 +1,361 @@
package de.jeanlucmakiola.agendula.data.tasks
import android.content.ContentValues
import android.content.Context
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.TasksContract.Tasks
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskReminder
import org.junit.After
import org.junit.Assert.assertThrows
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import kotlin.time.Clock
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Instant
/**
* Recurrence against a real external provider (#135). Skipped unless one is
* installed and its permissions are granted, so it only runs where set up:
* `pm grant <app> org.tasks.permission.READ_TASKS` (and WRITE_TASKS).
*/
@RunWith(AndroidJUnit4::class)
class ExternalProviderRecurrenceTest {
private val context = ApplicationProvider.getApplicationContext<Context>()
private val resolver = ProviderResolver(AndroidProviderEnvironment(context)).apply {
storageMode = StorageMode.EXTERNAL
}
private val source = AndroidTasksDataSource(context, resolver)
private var listId = 0L
@Before
fun setUp() {
val provider = resolver.resolve()
assumeTrue("no external tasks provider, or not granted", provider != null && resolver.hasPermission(provider))
listId = source.createLocalList("Recurrence test", 0)
}
@After
fun tearDown() {
if (listId != 0L) source.deleteList(listId)
}
private fun dailyTask(): Long {
// Milliseconds and all, as the "start today" default gives.
val start = Clock.System.now() + 1.hours
return source.insertTask(
TaskForm(title = "Daily", listId = listId, start = start, due = start + 1.hours, rrule = "FREQ=DAILY"),
reminders = null,
)
}
private fun storedRule(taskId: Long): String? = context.contentResolver.query(
TasksContract.tasksUri(resolver.resolve()!!.authority),
arrayOf(Tasks.RRULE),
"${Tasks.ID} = ?",
arrayOf(taskId.toString()),
null,
)?.use { c -> if (c.moveToFirst()) c.getString(0) else null }
private fun setRule(taskId: Long, rule: String) {
context.contentResolver.update(
TasksContract.tasksUri(resolver.resolve()!!.authority),
ContentValues().apply { put(Tasks.RRULE, rule) },
"${Tasks.ID} = ?",
arrayOf(taskId.toString()),
)
}
@Test
fun aSeriesReadsAsRecurringWithItsRule() {
val id = dailyTask()
val listed = source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id }.minBy { it.occurrenceStart!! }
assertThat(listed.isRecurring).isTrue()
assertThat(listed.isOccurrence).isTrue()
assertThat(listed.recurrenceRule).isEqualTo("FREQ=DAILY")
val opened = source.task(id)!!
assertThat(opened.recurrenceRule).isEqualTo("FREQ=DAILY")
}
@Test
fun savingTheSeriesKeepsItsRule() {
val id = dailyTask()
val task = source.task(id)!!
source.updateSeries(
id,
task.occurrenceStart!!,
TaskForm(title = "Daily, renamed", listId = listId, start = task.start, due = task.due, rrule = task.recurrenceRule),
reminders = null,
)
assertThat(storedRule(id)).isEqualTo("FREQ=DAILY")
}
@Test
fun completingAnOccurrenceMovesTheSeriesOn() {
val id = dailyTask()
val task = source.task(id)!!
source.setCompletedInstance(id, task.occurrenceStart!!, completed = true)
assertSeriesMovedOn(id, task.occurrenceStart!!)
}
@Test
fun aTickedOccurrenceReadsAsItsCopy() {
val id = dailyTask()
val anchor = source.task(id)!!.occurrenceStart!!
source.setCompletedInstance(id, anchor, completed = true)
val read = source.task(id, anchor)!!
assertThat(read.taskId).isNotEqualTo(id)
assertThat(read.isClosed).isTrue()
}
@Test
fun undoingATickMovesTheSeriesBack() {
val id = dailyTask()
val anchor = source.task(id)!!.occurrenceStart!!
source.setCompletedInstance(id, anchor, completed = true)
val copy = source.task(id, anchor)!!.taskId
source.setCompletedInstance(id, anchor, completed = false)
val read = source.task(id, anchor)!!
assertThat(read.taskId).isEqualTo(id)
assertThat(read.isClosed).isFalse()
assertThat(source.task(copy)).isNull()
}
@Test
fun reopeningTheCopyItselfMovesTheSeriesBack() {
val id = dailyTask()
val anchor = source.task(id)!!.occurrenceStart!!
source.setCompletedInstance(id, anchor, completed = true)
val copy = source.task(id, anchor)!!.taskId
source.setCompleted(copy, completed = false)
assertThat(source.task(id, anchor)!!.taskId).isEqualTo(id)
assertThat(source.task(copy)).isNull()
}
@Test
fun aSeriesChangedSinceTheTickReopensOnlyTheCopy() {
val id = dailyTask()
val anchor = source.task(id)!!.occurrenceStart!!
source.setCompletedInstance(id, anchor, completed = true)
val copy = source.task(id, anchor)!!.taskId
setRule(id, "FREQ=WEEKLY")
source.setCompletedInstance(id, anchor, completed = false)
assertThat(source.task(copy)!!.isClosed).isFalse()
assertThat(storedRule(id)).isEqualTo("FREQ=WEEKLY")
}
@Test
fun anOccurrenceThatIsGoneReadsAsNothing() {
val id = dailyTask()
val anchor = source.task(id)!!.occurrenceStart!!
assertThat(source.task(id, anchor + 30.hours)).isNull()
}
@Test
fun aSeriesStoredWithMillisecondsStillMovesOn() {
val id = legacySeries()
val task = source.task(id)!!
source.setCompletedInstance(id, task.occurrenceStart!!, completed = true)
assertSeriesMovedOn(id, task.occurrenceStart)
}
@Test
fun anUnknownOccurrenceDoesNotCloseTheSeries() {
val id = dailyTask()
val anchor = source.task(id)!!.occurrenceStart!!
assertThrows(TaskWriteFailedException::class.java) {
source.setCompletedInstance(id, anchor + 30.hours, completed = true)
}
assertThat(source.task(id)!!.isClosed).isFalse()
}
@Test
fun roundingALegacySeriesLeavesItUnmodified() {
val id = legacySeries()
val before = storedColumn(id, Tasks.LAST_MODIFIED)
source.task(id)
assertThat(storedStart(id)!! % 1000).isEqualTo(0L)
assertThat(storedColumn(id, Tasks.LAST_MODIFIED)).isEqualTo(before)
}
/** A daily series as builds before whole-second writes left it, already in sync with its server. */
private fun legacySeries(): Long {
val start = (Clock.System.now() + 1.hours).toEpochMilliseconds() / 1000 * 1000
return insertAsSyncAdapter(
Tasks.LIST_ID to listId,
Tasks.TITLE to "Old daily",
Tasks.DTSTART to start + 345,
Tasks.DUE to start + 3_600_345,
Tasks.TZ to "UTC",
Tasks.RRULE to "FREQ=DAILY",
).also {
// The app's own reads may already have run the one-time rounding.
context.getSharedPreferences("provider_migrations", Context.MODE_PRIVATE).edit().clear().commit()
}
}
private fun insertAsSyncAdapter(vararg values: Pair<String, Any>): Long {
val uri = TasksContract.asSyncAdapter(
TasksContract.tasksUri(resolver.resolve()!!.authority),
TasksContract.LOCAL_ACCOUNT_NAME,
TasksContract.LOCAL_ACCOUNT_TYPE,
)
val cv = ContentValues().apply {
values.forEach { (k, v) -> if (v is Long) put(k, v) else put(k, v.toString()) }
}
return context.contentResolver.insert(uri, cv)!!.lastPathSegment!!.toLong()
}
/** Edits the series' first occurrence on its own and returns the override that makes. */
private fun overrideOf(id: Long): Task {
val task = source.task(id)!!
source.updateInstance(
id,
task.occurrenceStart!!,
TaskForm(title = "Just this once", listId = listId, start = task.start, due = task.due),
reminders = null,
)
return source.tasks(TaskQuery(listId = listId)).single { it.seriesId == id && it.taskId != id }
}
@Test
fun anOverrideBelongsToItsSeries() {
val id = dailyTask()
val anchor = source.task(id)!!.occurrenceStart
val override = overrideOf(id)
assertThat(override.title).isEqualTo("Just this once")
assertThat(override.isOccurrence).isTrue()
assertThat(override.occurrenceStart).isEqualTo(anchor)
assertThat(override.recurrenceRule).isEqualTo("FREQ=DAILY")
assertThat(source.task(override.taskId, override.occurrenceStart)?.taskId).isEqualTo(override.taskId)
}
@Test
fun anAllDayDueAddedToTheSeriesStaysOnItsDay() {
val day = Clock.System.now().toEpochMilliseconds() / 86_400_000 * 86_400_000
val id = source.insertTask(
TaskForm(title = "All day", listId = listId, start = Instant.fromEpochMilliseconds(day), isAllDay = true, rrule = "FREQ=DAILY"),
reminders = null,
)
val task = source.task(id)!!
// Local midnight the next day, as the date picker hands it over.
val localMidnight = Instant.fromEpochMilliseconds(day + 86_400_000 - java.util.TimeZone.getDefault().getOffset(day))
source.updateSeries(
id,
task.occurrenceStart!!,
TaskForm(title = "All day", listId = listId, start = task.start, due = localMidnight, isAllDay = true, rrule = "FREQ=DAILY"),
reminders = null,
)
assertThat(storedColumn(id, Tasks.DUE)!!.toLong() % 86_400_000).isEqualTo(0L)
}
@Test
fun savingAllFromAnOverrideKeepsTheSeriesWhereItIs() {
val id = dailyTask()
val before = storedStart(id)
val override = overrideOf(id)
source.updateSeries(
id,
override.occurrenceStart!!,
TaskForm(title = "Renamed", listId = listId, start = override.start, due = override.due, rrule = override.recurrenceRule),
reminders = null,
)
assertThat(storedStart(id)).isEqualTo(before)
assertThat(storedRule(id)).isEqualTo("FREQ=DAILY")
}
@Test
fun deletingAnOverrideExcludesItsOccurrence() {
val id = dailyTask()
val override = overrideOf(id)
source.deleteOverride(override.taskId, override.occurrenceStart!!)
// Excluded on the master, not just the override dropped.
assertThat(storedColumn(id, Tasks.EXDATE)).isNotNull()
val open = source.tasks(TaskQuery(listId = listId)).filter { it.seriesId == id }
assertThat(open.map { it.occurrenceStart }).doesNotContain(override.occurrenceStart)
assertThat(storedRule(id)).isEqualTo("FREQ=DAILY")
}
@Test
fun editingAnOverrideAgainKeepsItsNewTagsAndReminders() {
val id = dailyTask()
val override = overrideOf(id)
val form = TaskForm(
title = override.title,
listId = listId,
start = override.start,
due = override.due,
tags = listOf("urgent"),
reminders = listOf(TaskReminder(minutesBefore = 15)),
)
source.updateInstance(override.taskId, override.occurrenceStart!!, form, form.reminders)
assertThat(source.task(override.taskId)!!.tags).containsExactly("urgent")
assertThat(source.reminders()[override.taskId]).containsExactly(TaskReminder(minutesBefore = 15))
assertThat(source.task(id)?.tags.orEmpty()).isEmpty()
}
@Test
fun anOccurrenceIsFoundByItsAnchorToTheSecond() {
val id = dailyTask()
val override = overrideOf(id)
val found = source.task(id, override.occurrenceStart!! + 345.milliseconds)
assertThat(found?.taskId).isEqualTo(override.taskId)
}
private fun storedStart(taskId: Long): Long? = storedColumn(taskId, Tasks.DTSTART)?.toLong()
private fun storedColumn(taskId: Long, column: String): String? = context.contentResolver.query(
TasksContract.tasksUri(resolver.resolve()!!.authority),
arrayOf(column),
"${Tasks.ID} = ?",
arrayOf(taskId.toString()),
null,
)?.use { c -> if (c.moveToFirst()) c.getString(0) else null }
private fun assertSeriesMovedOn(id: Long, completed: Instant) {
assertThat(storedRule(id)).isNotNull()
val open = source.tasks(TaskQuery(listId = listId)).filter { it.seriesId == id }
assertThat(open).isNotEmpty()
assertThat(open.minOf { it.occurrenceStart!! }).isGreaterThan(completed)
}
}
@@ -124,6 +124,7 @@ class AgendulaTaskProviderTest {
@Test
fun aBadWriteReachesTheClientAsIllegalArgument() {
assumeTrue("Agendula is running on an external provider", ownStore())
// 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)
@@ -135,6 +136,7 @@ class AgendulaTaskProviderTest {
@Test
fun aFailingBatchWritesNothing() {
assumeTrue("Agendula is running on an external provider", ownStore())
val missingList = Long.MAX_VALUE
val operations = arrayListOf(
ContentProviderOperation.newInsert(uri("tasks")).withValue("list_id", missingList).withValue("title", "a").build(),
@@ -145,6 +147,9 @@ class AgendulaTaskProviderTest {
.use { assertThat(it.count).isEqualTo(0) }
}
/** Whether the app's own store is active: only then does the provider take writes at all. */
private fun ownStore() = runCatching { resolver.delete(uri("tasks"), "_id = -1", null) }.isSuccess
/** stdout and stderr of [command]; the denial lands on stderr. */
private fun shell(command: String): String {
val (stdout, stdin, stderr) = InstrumentationRegistry.getInstrumentation().uiAutomation
@@ -734,9 +734,11 @@ class ProviderWriterTest {
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))
// The second occurrence: ticking the first moves the series on instead.
val second = today + 1.days
val row = readRow(ProviderTable.INSTANCES, instanceId(second))
writer.update(ProviderTable.INSTANCES, row + (Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
writer.update(ProviderTable.INSTANCES, row + (Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(second)), 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)
@@ -795,6 +797,20 @@ class ProviderWriterTest {
private fun store() = RoomTasksDataSource(db)
@Test
fun reopeningATickedOccurrencesCopyMovesTheSeriesBack() {
val series = db.tasks().insert(
TaskEntity(listId = home, uid = "w", title = "Weekly", dtstart = today, rrule = "FREQ=WEEKLY", timezone = "UTC"),
)
store().setCompletedInstance(series, today, true)
val copy = db.tasks().allIn(home).single { it.id != series }
writer.update(ProviderTable.TASKS, mapOf(Tasks.STATUS to 0), byId(ProviderTable.TASKS, copy.id), null)
assertThat(task(series)!!.dtstart).isEqualTo(today)
assertThat(task(copy.id)).isNull()
}
/** 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 }
@@ -14,6 +14,7 @@ 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.Instant
/**
@@ -211,7 +212,8 @@ class RoomTasksDataSourceTest {
@Test
fun completingOneOccurrenceLeavesTheRestOfTheSeriesOpen() {
val id = source.insertTask(form(title = "Water the plants"))
makeRecurring(id, now)
// Overdue occurrences before it, so it is not the first and forks an override.
makeRecurring(id, now - 14.days)
val open = { source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id } }
val before = open()
val target = before.first { it.distanceFromCurrent == 0 }
@@ -237,7 +239,8 @@ class RoomTasksDataSourceTest {
@Test
fun reopeningACompletedOccurrenceReusesItsOverride() {
val id = source.insertTask(form(title = "Water the plants"))
makeRecurring(id, now)
// Overdue occurrences before it, so it is not the first and forks an override.
makeRecurring(id, now - 14.days)
val target = source.tasks(TaskQuery(listId = listId))
.first { it.taskId == id && it.distanceFromCurrent == 0 }
@@ -254,7 +257,8 @@ class RoomTasksDataSourceTest {
@Test
fun cancellingOneOccurrenceLeavesTheRestOfTheSeriesOpen() {
val id = source.insertTask(form(title = "Water the plants"))
makeRecurring(id, now)
// Overdue occurrences before it, so it is not the first and forks an override.
makeRecurring(id, now - 14.days)
val target = source.tasks(TaskQuery(listId = listId))
.first { it.taskId == id && it.distanceFromCurrent == 0 }
@@ -269,6 +273,759 @@ class RoomTasksDataSourceTest {
assertThat(db.tasks().override(id, target.occurrenceStart)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
}
@Test
fun completingTheFirstOccurrenceMovesTheSeriesOnAndKeepsACopy() {
val at = now
val id = source.insertTask(form(title = "Water the plants"))
makeRecurring(id, at, rule = "FREQ=WEEKLY;COUNT=4")
source.setCompletedInstance(id, at, completed = true)
val master = db.tasks().entity(id)!!
assertThat(master.status).isEqualTo(TaskStatus.NEEDS_ACTION)
assertThat(master.dtstart).isEqualTo(at + 7.days)
assertThat(master.due).isEqualTo(at + 8.days)
assertThat(master.rrule).isEqualTo("FREQ=WEEKLY;COUNT=3")
assertThat(master.isDirty).isTrue()
assertThat(db.tasks().overrides(id)).isEmpty()
val copy = db.tasks().allIn(listId).single { it.id != id }
assertThat(copy.status).isEqualTo(TaskStatus.COMPLETED)
assertThat(copy.uid).isNotEqualTo(master.uid)
assertThat(copy.rrule).isNull()
assertThat(copy.masterId).isNull()
assertThat(copy.dtstart).isEqualTo(at)
assertThat(copy.title).isEqualTo("Water the plants")
}
@Test
fun reopeningTheOccurrenceMovesTheSeriesBack() {
val at = now
val id = source.insertTask(form(title = "Water the plants"))
makeRecurring(id, at, rule = "FREQ=WEEKLY;COUNT=4")
source.setCompletedInstance(id, at, completed = true)
source.setCompletedInstance(id, at, completed = false)
val master = db.tasks().entity(id)!!
assertThat(master.dtstart).isEqualTo(at)
assertThat(master.rrule).isEqualTo("FREQ=WEEKLY;COUNT=4")
assertThat(db.tasks().allIn(listId).filter { !it.isDeleted }.map { it.id }).containsExactly(id)
}
@Test
fun completingAnEditedFirstOccurrenceKeepsItsEdits() {
val at = now
val id = source.insertTask(form(title = "Water the plants"))
makeRecurring(id, at)
source.updateInstance(id, at, TaskForm(title = "Water the cactus", listId = listId, start = at, due = at + 1.days), reminders = null)
val override = db.tasks().override(id, at)!!
source.setCompletedInstance(override.id, at, completed = true)
assertThat(db.tasks().overrides(id)).isEmpty()
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 7.days)
val copy = db.tasks().allIn(listId).single { it.id != id }
assertThat(copy.title).isEqualTo("Water the cactus")
assertThat(copy.status).isEqualTo(TaskStatus.COMPLETED)
}
@Test
fun completingTheLastOccurrenceClosesTheSeries() {
val at = now
val id = source.insertTask(form(title = "Water the plants"))
makeRecurring(id, at, rule = "FREQ=WEEKLY;COUNT=1")
source.setCompletedInstance(id, at, completed = true)
assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.COMPLETED)
assertThat(db.tasks().allIn(listId).map { it.id }).containsExactly(id)
source.setCompletedInstance(id, at, completed = false)
assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
}
@Test
fun cancellingTheFirstOccurrenceMovesTheSeriesOn() {
val at = now
val id = source.insertTask(form(title = "Water the plants"))
makeRecurring(id, at)
source.setCancelledInstance(id, at, cancelled = true)
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 7.days)
assertThat(db.tasks().allIn(listId).single { it.id != id }.status).isEqualTo(TaskStatus.CANCELLED)
}
@Test
fun aSeriesWhoseStartWasDeletedMovesPastTheTickedOne() {
val at = now
val id = source.insertTask(form(title = "Weekly"))
makeRecurring(id, at)
source.deleteOccurrence(id, at)
source.setCompletedInstance(id, at + 7.days, completed = true)
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 14.days)
}
@Test
fun countSpendsExcludedOccurrencesAndReopeningRestoresThem() {
val at = now
val id = source.insertTask(form(title = "Daily"))
makeRecurring(id, at, rule = "FREQ=DAILY;COUNT=4")
source.deleteOccurrence(id, at + 1.days)
val before = db.tasks().entity(id)!!
source.setCompletedInstance(id, at, completed = true)
val moved = db.tasks().entity(id)!!
assertThat(moved.dtstart).isEqualTo(at + 2.days)
assertThat(moved.rrule).isEqualTo("FREQ=DAILY;COUNT=2")
source.setCompletedInstance(id, at, completed = false)
val back = db.tasks().entity(id)!!
assertThat(back.dtstart).isEqualTo(before.dtstart)
assertThat(back.rrule).isEqualTo(before.rrule)
assertThat(back.exdate).isEqualTo(before.exdate)
}
@Test
fun reopeningTheCopyItselfMovesTheSeriesBack() {
val at = now
val id = source.insertTask(form(title = "Weekly"))
makeRecurring(id, at)
source.setCompletedInstance(id, at, completed = true)
val copy = db.tasks().allIn(listId).single { it.id != id }
source.setCompleted(copy.id, completed = false)
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at)
assertThat(db.tasks().allIn(listId).filter { !it.isDeleted }.map { it.id }).containsExactly(id)
}
@Test
fun undoingAnEditedOccurrenceBringsItsOverrideBack() {
val at = now
val id = source.insertTask(form(title = "Weekly"))
makeRecurring(id, at)
source.updateInstance(id, at, TaskForm(title = "Just this once", listId = listId, start = at, due = at + 1.days), reminders = null)
val override = db.tasks().override(id, at)!!
source.setCompletedInstance(override.id, at, completed = true)
source.setCompletedInstance(override.id, at, completed = false)
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at)
val back = db.tasks().override(id, at)!!
assertThat(back.title).isEqualTo("Just this once")
assertThat(back.status).isEqualTo(TaskStatus.NEEDS_ACTION)
}
@Test
fun aStaleTickOfAnOccurrenceMovedPastForksNothing() {
val at = now
val id = source.insertTask(form(title = "Weekly"))
makeRecurring(id, at)
source.setCompletedInstance(id, at, completed = true)
source.setCompletedInstance(id, at, completed = true)
assertThat(db.tasks().overrides(id)).isEmpty()
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 7.days)
}
@Test
fun occurrencesClosedBeforeTheUpdateNoLongerHoldTheSeriesBack() {
val at = now
val id = source.insertTask(form(title = "Weekly"))
makeRecurring(id, at)
val master = db.tasks().entity(id)!!
db.tasks().insert(
master.copy(id = 0, masterId = id, recurrenceId = at, rrule = null, status = TaskStatus.COMPLETED, completedAt = at),
)
source.setCompletedInstance(id, at + 7.days, completed = true)
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 14.days)
assertThat(db.tasks().overrides(id)).isEmpty()
assertThat(db.tasks().allIn(listId).count { it.id != id && it.status == TaskStatus.COMPLETED }).isEqualTo(2)
}
@Test
fun theTickedOccurrenceStillResolvesToItsCopy() {
val at = now
val id = source.insertTask(form(title = "Weekly"))
makeRecurring(id, at)
val occurrence = source.task(id, at)!!.occurrenceStart!!
source.setCompletedInstance(id, occurrence, completed = true)
val found = source.task(id, occurrence)!!
assertThat(found.taskId).isNotEqualTo(id)
assertThat(found.isCompleted).isTrue()
assertThat(found.occurrenceStart).isEqualTo(occurrence)
}
@Test
fun theNextOccurrenceStartsWithoutTheLastOnesProgress() {
val at = now
val id = source.insertTask(form(title = "Weekly"))
makeRecurring(id, at)
db.tasks().update(db.tasks().entity(id)!!.copy(status = TaskStatus.IN_PROCESS, percentComplete = 60))
source.setCompletedInstance(id, at, completed = true)
val master = db.tasks().entity(id)!!
assertThat(master.status).isEqualTo(TaskStatus.NEEDS_ACTION)
assertThat(master.percentComplete).isNull()
}
@Test
fun theCopyOfAnEditedOccurrenceKeepsItsOwnProperties() {
val at = now
val id = source.insertTask(form(title = "Weekly"))
makeRecurring(id, at)
val master = db.tasks().entity(id)!!
val override = db.tasks().insert(
master.copy(id = 0, masterId = id, recurrenceId = at, rrule = null, unknownProperties = "COMMENT:only this one"),
)
source.setCompletedInstance(override, at, completed = true)
assertThat(db.tasks().entity(override)!!.unknownProperties!!.lines()).contains("COMMENT:only this one")
}
@Test
fun aRuleWithAGapOfMoreThanTenYearsKeepsGoing() {
val at = now
val id = source.insertTask(form(title = "Rare"))
makeRecurring(id, at, rule = "FREQ=YEARLY;INTERVAL=11")
source.setCompletedInstance(id, at, completed = true)
val master = db.tasks().entity(id)!!
assertThat(master.status).isEqualTo(TaskStatus.NEEDS_ACTION)
assertThat(master.dtstart!!).isGreaterThan(at + (366 * 10).days)
}
@Test
fun reopeningKeepsWhatChangedOnTheSeriesSince() {
val at = now
val id = source.insertTask(form(title = "Weekly"))
makeRecurring(id, at)
source.setCompletedInstance(id, at, completed = true)
source.deleteOccurrence(id, at + 28.days)
source.setCompletedInstance(id, at, completed = false)
val master = db.tasks().entity(id)!!
assertThat(master.dtstart).isEqualTo(at)
assertThat(master.exdate).isNotNull()
assertThat(source.tasks(TaskQuery(listId = listId)).map { it.occurrenceStart?.epochSeconds })
.doesNotContain((at + 28.days).epochSeconds)
}
@Test
fun aSeriesWithExtraDatesKeepsTheOverrideModel() {
val at = now
val id = source.insertTask(form(title = "Weekly"))
makeRecurring(id, at)
db.tasks().update(db.tasks().entity(id)!!.copy(rdate = "20991231T090000Z"))
source.setCompletedInstance(id, at, completed = true)
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at)
assertThat(db.tasks().overrides(id).single().status).isEqualTo(TaskStatus.COMPLETED)
}
@Test
fun aDeletedSeriesIsNotBroughtBackByItsCopy() {
val at = now
val work = syncedList()
val id = source.insertTask(form(title = "Weekly").copy(listId = work))
makeRecurring(id, at)
source.setCompletedInstance(id, at, completed = true)
val copy = db.tasks().allIn(work).single { it.id != id }
source.deleteWithSubtasks(id)
source.setCompleted(copy.id, completed = false)
assertThat(db.tasks().entity(copy.id)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
assertThat(db.tasks().entity(copy.id)!!.isDeleted).isFalse()
}
@Test
fun aSeriesEndedOnItsLastOpenOccurrenceReopens() {
val at = now
val id = source.insertTask(form(title = "Three"))
makeRecurring(id, at, rule = "FREQ=WEEKLY;COUNT=3")
val master = db.tasks().entity(id)!!
listOf(at, at + 7.days).forEach { done ->
db.tasks().insert(master.copy(id = 0, masterId = id, recurrenceId = done, rrule = null, status = TaskStatus.COMPLETED))
}
source.setCompletedInstance(id, at + 14.days, completed = true)
assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.COMPLETED)
// Nothing to move past: the closed occurrences stay the series' own.
assertThat(db.tasks().overrides(id)).hasSize(2)
source.setCompletedInstance(id, at + 14.days, completed = false)
assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
}
@Test
fun reopeningAnOlderCopyReopensItOnItsOwn() {
val at = now
val id = source.insertTask(form(title = "Weekly"))
makeRecurring(id, at)
source.setCompletedInstance(id, at, completed = true)
source.setCompletedInstance(id, at + 7.days, completed = true)
source.setCompletedInstance(id, at, completed = false)
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 14.days)
val reopened = db.tasks().allIn(listId).single { it.id != id && it.dtstart == at }
assertThat(reopened.status).isEqualTo(TaskStatus.NEEDS_ACTION)
assertThat(db.advancedOccurrences().forCopy(reopened.id)!!.undoable).isFalse()
}
@Test
fun aSeriesMovesPastOccurrencesAlreadyClosedAfterIt() {
val at = now
val id = source.insertTask(form(title = "Weekly"))
makeRecurring(id, at)
val master = db.tasks().entity(id)!!
db.tasks().insert(master.copy(id = 0, masterId = id, recurrenceId = at + 7.days, rrule = null, status = TaskStatus.COMPLETED))
source.setCompletedInstance(id, at, completed = true)
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 14.days)
assertThat(db.tasks().overrides(id)).isEmpty()
}
@Test
fun aDetachedOccurrenceDeletedSinceStaysExcluded() {
val at = now
val id = source.insertTask(form(title = "Weekly"))
makeRecurring(id, at)
val master = db.tasks().entity(id)!!
val earlier = db.tasks().insert(master.copy(id = 0, masterId = id, recurrenceId = at, rrule = null, status = TaskStatus.COMPLETED))
source.setCompletedInstance(id, at + 7.days, completed = true)
source.deleteTask(earlier)
source.setCompletedInstance(id, at + 7.days, completed = false)
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at)
assertThat(source.tasks(TaskQuery(listId = listId)).map { it.occurrenceStart?.epochSeconds })
.doesNotContain(at.epochSeconds)
}
@Test
fun aNestedComponentKeepsItsOwnUid() {
val at = now
val id = source.insertTask(form(title = "Weekly"))
makeRecurring(id, at)
val master = db.tasks().entity(id)!!
val override = db.tasks().insert(
master.copy(
id = 0, masterId = id, recurrenceId = at, rrule = null,
unknownProperties = "BEGIN:VALARM\r\nUID:alarm-1\r\nACTION:AUDIO\r\nEND:VALARM",
),
)
source.setCompletedInstance(override, at, completed = true)
assertThat(db.tasks().entity(override)!!.unknownProperties!!.lines()).contains("UID:alarm-1")
}
@Test
fun reopeningTheCopyInTheEditorMovesTheSeriesBack() {
val at = now
val id = source.insertTask(form(title = "Weekly"))
makeRecurring(id, at)
source.setCompletedInstance(id, at, completed = true)
val copy = db.tasks().allIn(listId).single { it.id != id }
source.updateTask(
copy.id,
TaskForm(title = copy.title.orEmpty(), listId = listId, start = copy.dtstart, due = copy.due, percentComplete = 0),
reminders = null,
)
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at)
}
@Test
fun anEditedCopyReopensAsATaskOfItsOwn() {
val at = now
val id = source.insertTask(form(title = "Weekly"))
makeRecurring(id, at)
source.setCompletedInstance(id, at, completed = true)
val copy = db.tasks().allIn(listId).single { it.id != id }
db.tasks().update(copy.copy(description = "notes"))
source.updateTask(copy.id, TaskForm(title = "Renamed", listId = listId, start = copy.dtstart, due = copy.due, percentComplete = 100), null)
source.setCompleted(copy.id, completed = false)
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 7.days)
assertThat(db.tasks().entity(copy.id)!!.title).isEqualTo("Renamed")
assertThat(db.tasks().entity(copy.id)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
}
@Test
fun aCopyWithASubtaskReopensOnItsOwn() {
val at = now
val id = source.insertTask(form(title = "Weekly"))
makeRecurring(id, at)
source.setCompletedInstance(id, at, completed = true)
val copy = db.tasks().allIn(listId).single { it.id != id }
source.insertTask(form(title = "Part").copy(parentId = copy.id))
source.setCompleted(copy.id, completed = false)
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 7.days)
assertThat(db.tasks().entity(copy.id)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
}
@Test
fun aRecurringSubtaskKeepsTheOverrideModel() {
val at = now
val parent = source.insertTask(form(title = "Parent"))
val id = source.insertTask(form(title = "Daily part").copy(parentId = parent))
makeRecurring(id, at, rule = "FREQ=DAILY")
source.setCompletedInstance(id, at, completed = true)
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at)
assertThat(db.tasks().overrides(id)).hasSize(1)
}
@Test
fun movingARoutineOnReopensItsCompletedSubtasks() {
val at = now
val id = source.insertTask(form(title = "Routine"))
makeRecurring(id, at)
val done = source.insertTask(form(title = "Step").copy(parentId = id))
val nested = source.insertTask(form(title = "Nested").copy(parentId = done))
val cancelled = source.insertTask(form(title = "Skipped").copy(parentId = id))
val daily = source.insertTask(form(title = "Daily part").copy(parentId = id))
makeRecurring(daily, at, rule = "FREQ=DAILY")
source.setCompleted(done, completed = true)
source.setCompleted(nested, completed = true)
source.setCancelled(cancelled, cancelled = true)
source.setCompletedInstance(daily, at, completed = true)
source.setCompletedInstance(id, at, completed = true)
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 7.days)
assertThat(db.tasks().entity(done)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
assertThat(db.tasks().entity(done)!!.completedAt).isNull()
assertThat(db.tasks().entity(nested)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
assertThat(db.tasks().entity(cancelled)!!.status).isEqualTo(TaskStatus.CANCELLED)
// A recurring subtask has its own series, and its closed occurrence stays closed.
assertThat(db.tasks().overrides(daily).single().status).isEqualTo(TaskStatus.COMPLETED)
}
@Test
fun aHalfDoneSubtaskStartsOverToo() {
val at = now
val id = source.insertTask(form(title = "Routine"))
makeRecurring(id, at)
val step = source.insertTask(form(title = "Step", percentComplete = 40).copy(parentId = id))
db.tasks().update(db.tasks().entity(step)!!.copy(status = TaskStatus.IN_PROCESS, percentComplete = 40))
source.setCompletedInstance(id, at, completed = true)
assertThat(db.tasks().entity(step)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
assertThat(db.tasks().entity(step)!!.percentComplete).isNull()
}
@Test
fun movingARoutineBackRestoresTheSubtasksItReset() {
val at = now
val id = source.insertTask(form(title = "Routine"))
makeRecurring(id, at)
val done = source.insertTask(form(title = "Step").copy(parentId = id))
val touched = source.insertTask(form(title = "Touched since").copy(parentId = id))
source.setCompleted(done, completed = true)
source.setCompleted(touched, completed = true)
val completedAt = db.tasks().entity(done)!!.completedAt
source.setCompletedInstance(id, at, completed = true)
db.tasks().update(db.tasks().entity(touched)!!.copy(percentComplete = 30))
source.setCompletedInstance(id, at, completed = false)
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at)
assertThat(db.tasks().entity(done)!!.status).isEqualTo(TaskStatus.COMPLETED)
assertThat(db.tasks().entity(done)!!.completedAt).isEqualTo(completedAt)
// Changed after the reset: the user's state wins.
assertThat(db.tasks().entity(touched)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
assertThat(db.tasks().entity(touched)!!.percentComplete).isEqualTo(30)
}
@Test
fun aDatedSubtaskMovesWithItsRoutineAndBack() {
val at = now
val id = source.insertTask(form(title = "Routine"))
makeRecurring(id, at)
val step = source.insertTask(form(title = "Step", due = at + 1.days).copy(parentId = id))
val undated = source.insertTask(form(title = "Undated").copy(parentId = id))
source.setCompleted(step, completed = true)
val due = db.tasks().entity(step)!!.due!!
source.setCompletedInstance(id, at, completed = true)
// A week of wall-clock time: an hour either way across a DST change.
assertThat(db.tasks().entity(step)!!.due!! - due).isIn(com.google.common.collect.Range.closed(7.days - 1.hours, 7.days + 1.hours))
assertThat(db.tasks().entity(step)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
assertThat(db.tasks().entity(undated)!!.due).isNull()
source.setCompletedInstance(id, at, completed = false)
assertThat(db.tasks().entity(step)!!.due).isEqualTo(due)
assertThat(db.tasks().entity(step)!!.status).isEqualTo(TaskStatus.COMPLETED)
}
@Test
fun aMarkedCopyUnderARoutineIsLeftAlone() {
val at = now
val id = source.insertTask(form(title = "Routine"))
makeRecurring(id, at)
val routineUid = db.tasks().entity(id)!!.uid
val copy = source.insertTask(form(title = "Daily part").copy(parentId = id))
val elsewhere = source.insertTask(form(title = "Moved here").copy(parentId = id))
source.setCompleted(copy, completed = true)
source.setCompleted(elsewhere, completed = true)
db.tasks().update(db.tasks().entity(copy)!!.copy(unknownProperties = "X-AGENDULA-TICK-COPY:$routineUid"))
db.tasks().update(db.tasks().entity(elsewhere)!!.copy(unknownProperties = "X-AGENDULA-TICK-COPY:another-parent"))
source.setCompletedInstance(id, at, completed = true)
assertThat(db.tasks().entity(copy)!!.status).isEqualTo(TaskStatus.COMPLETED)
// Its marker names a parent it no longer sits under: an ordinary sub-task here.
assertThat(db.tasks().entity(elsewhere)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
}
@Test
fun aLegacyResetRecordRestoresTheStateAndLeavesTheDates() {
val at = now
val id = source.insertTask(form(title = "Routine"))
makeRecurring(id, at)
val step = source.insertTask(form(title = "Step", due = at + 1.days).copy(parentId = id))
source.setCompleted(step, completed = true)
source.setCompletedInstance(id, at, completed = true)
val record = db.advancedOccurrences().forMaster(id).single()
db.advancedOccurrences().insert(record.copy(resetSubtasks = "$step,COMPLETED,100,"))
val movedDue = db.tasks().entity(step)!!.due
source.setCompletedInstance(id, at, completed = false)
assertThat(db.tasks().entity(step)!!.status).isEqualTo(TaskStatus.COMPLETED)
assertThat(db.tasks().entity(step)!!.due).isEqualTo(movedDue)
}
@Test
fun aRoutineTickedOnItsLastOccurrenceLeavesItsSubtasksDone() {
val at = now
val id = source.insertTask(form(title = "Once more"))
makeRecurring(id, at, rule = "FREQ=WEEKLY;COUNT=1")
val step = source.insertTask(form(title = "Step").copy(parentId = id))
source.setCompleted(step, completed = true)
source.setCompletedInstance(id, at, completed = true)
assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.COMPLETED)
assertThat(db.tasks().entity(step)!!.status).isEqualTo(TaskStatus.COMPLETED)
}
@Test
fun aSeriesReshapedSinceIsNotMovedBack() {
val at = now
val id = source.insertTask(form(title = "Ten"))
makeRecurring(id, at, rule = "FREQ=WEEKLY;COUNT=10")
source.setCompletedInstance(id, at, completed = true)
db.tasks().update(db.tasks().entity(id)!!.copy(rrule = "FREQ=DAILY;COUNT=5"))
source.setCompletedInstance(id, at, completed = false)
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 7.days)
assertThat(db.tasks().entity(id)!!.rrule).isEqualTo("FREQ=DAILY;COUNT=5")
assertThat(db.tasks().allIn(listId).single { it.id != id }.status).isEqualTo(TaskStatus.NEEDS_ACTION)
}
@Test
fun tickingTheSameOccurrenceAgainKeepsOneRecord() {
val at = now
val id = source.insertTask(form(title = "Weekly"))
makeRecurring(id, at)
source.setCompletedInstance(id, at, completed = true)
source.setCompletedInstance(id, at, completed = false)
source.setCompletedInstance(id, at, completed = true)
assertThat(db.advancedOccurrences().forMaster(id)).hasSize(1)
source.setCompletedInstance(id, at, completed = false)
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at)
}
@Test
fun anEditedCopyStillStandsForItsOccurrence() {
val at = now
val id = source.insertTask(form(title = "Weekly"))
makeRecurring(id, at)
source.setCompletedInstance(id, at, completed = true)
val copy = db.tasks().allIn(listId).single { it.id != id }
db.tasks().update(copy.copy(title = "Watered"))
assertThat(source.task(id, at)!!.taskId).isEqualTo(copy.id)
}
@Test
fun aDetachedOccurrenceWithALifeOfItsOwnStaysOut() {
val at = now
val id = source.insertTask(form(title = "Weekly"))
makeRecurring(id, at)
val master = db.tasks().entity(id)!!
val earlier = db.tasks().insert(master.copy(id = 0, masterId = id, recurrenceId = at, rrule = null, status = TaskStatus.COMPLETED))
source.setCompletedInstance(id, at + 7.days, completed = true)
source.insertTask(form(title = "Follow-up").copy(parentId = earlier))
source.setCompletedInstance(id, at + 7.days, completed = false)
assertThat(db.tasks().entity(earlier)!!.masterId).isNull()
assertThat(source.tasks(TaskQuery(listId = listId)).map { it.occurrenceStart?.epochSeconds })
.doesNotContain(at.epochSeconds)
}
@Test
fun reopeningAnotherOverrideOfAFinishedSeriesLeavesItsLastOccurrenceClosed() {
val at = now
val id = source.insertTask(form(title = "Three"))
makeRecurring(id, at, rule = "FREQ=WEEKLY;COUNT=3")
val master = db.tasks().entity(id)!!
val last = db.tasks().insert(master.copy(id = 0, masterId = id, recurrenceId = at + 14.days, rrule = null, status = TaskStatus.COMPLETED))
source.setCompletedInstance(id, at, completed = true)
source.setCompletedInstance(id, at + 7.days, completed = true)
assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.COMPLETED)
source.setCompletedInstance(last, at + 14.days, completed = false)
assertThat(db.tasks().entity(last)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.COMPLETED)
assertThat(source.task(id, at + 7.days)!!.isClosed).isTrue()
}
@Test
fun reopeningTheCopyWithNewRemindersKeepsThem() {
val at = now
val id = source.insertTask(form(title = "Weekly"))
makeRecurring(id, at)
source.setCompletedInstance(id, at, completed = true)
val copy = db.tasks().allIn(listId).single { it.id != id }
source.updateTask(
copy.id,
TaskForm(title = copy.title.orEmpty(), listId = listId, start = copy.dtstart, due = copy.due, percentComplete = 0),
reminders = listOf(TaskReminder(minutesBefore = 30)),
)
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 7.days)
assertThat(db.tasks().entity(copy.id)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
assertThat(db.alarms().forTask(copy.id)).hasSize(1)
}
@Test
fun aDetachedOccurrenceReopenedSinceStaysOut() {
val at = now
val id = source.insertTask(form(title = "Weekly"))
makeRecurring(id, at)
val master = db.tasks().entity(id)!!
val earlier = db.tasks().insert(master.copy(id = 0, masterId = id, recurrenceId = at, rrule = null, status = TaskStatus.COMPLETED))
source.setCompletedInstance(id, at + 7.days, completed = true)
source.setCompleted(earlier, completed = false)
source.setCompletedInstance(id, at + 7.days, completed = false)
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at)
assertThat(db.tasks().entity(earlier)!!.masterId).isNull()
assertThat(source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id }.map { it.occurrenceStart?.epochSeconds })
.doesNotContain(at.epochSeconds)
}
@Test
fun anOccurrenceWhoseCopyIsGoneReadsAsNothing() {
val at = now
val id = source.insertTask(form(title = "Weekly"))
makeRecurring(id, at)
source.setCompletedInstance(id, at, completed = true)
source.deleteTask(db.tasks().allIn(listId).single { it.id != id }.id)
assertThat(source.task(id, at)).isNull()
}
@Test
fun catchingUpOnAnOverdueSeriesKeepsEachTickUndoable() {
val at = now - 56.days
val id = source.insertTask(form(title = "Weekly"))
makeRecurring(id, at)
source.setCompletedInstance(id, at, completed = true)
val first = db.tasks().allIn(listId).single { it.id != id }
source.setCompletedInstance(id, at + 7.days, completed = true)
assertThat(db.advancedOccurrences().forCopy(first.id)).isNotNull()
}
@Test
fun aRuleTheServerReorderedStillMovesBack() {
val at = now
val id = source.insertTask(form(title = "Ten"))
makeRecurring(id, at, rule = "FREQ=WEEKLY;COUNT=10")
source.setCompletedInstance(id, at, completed = true)
db.tasks().update(db.tasks().entity(id)!!.copy(rrule = "count=9;FREQ=WEEKLY"))
source.setCompletedInstance(id, at, completed = false)
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at)
}
@Test
fun reopeningWithProgressKeepsIt() {
val at = now
val id = source.insertTask(form(title = "Weekly"))
makeRecurring(id, at)
source.setCompletedInstance(id, at, completed = true)
val copy = db.tasks().allIn(listId).single { it.id != id }
source.updateTask(
copy.id,
TaskForm(title = copy.title.orEmpty(), listId = listId, start = copy.dtstart, due = copy.due, percentComplete = 40),
reminders = null,
)
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at)
assertThat(db.tasks().override(id, at)!!.percentComplete).isEqualTo(40)
}
@Test
fun theEditorsTidyingDoesNotCountAsAnEdit() {
val at = now
val id = source.insertTask(form(title = "Weekly"))
makeRecurring(id, at)
db.tasks().update(db.tasks().entity(id)!!.copy(description = "From the server\n"))
source.setCompletedInstance(id, at, completed = true)
val copy = db.tasks().allIn(listId).single { it.id != id }
source.updateTask(
copy.id,
TaskForm(title = copy.title.orEmpty(), listId = listId, description = copy.description, start = copy.dtstart, due = copy.due, percentComplete = 0),
reminders = null,
)
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at)
}
@Test
fun completingANonRecurringTaskThroughTheInstancePathWritesTheRowItself() {
val id = source.insertTask(form(title = "Buy milk", due = now + 1.days))
@@ -657,10 +1414,12 @@ class RoomTasksDataSourceTest {
val outOfWindow = db.tasks().insert(
TaskEntity(listId = listId, uid = "old", title = "old", dtstart = longAgo, rrule = "FREQ=DAILY;COUNT=2", parentId = parent),
)
val at = now
val series = db.tasks().insert(
TaskEntity(listId = listId, uid = "s", title = "s", dtstart = now, rrule = "FREQ=DAILY;COUNT=3", parentId = parent),
TaskEntity(listId = listId, uid = "s", title = "s", dtstart = at, rrule = "FREQ=DAILY;COUNT=3", parentId = parent),
)
source.setCompletedInstance(series, now, true)
// Not the first occurrence, so it forks an override rather than a copy.
source.setCompletedInstance(series, at + 1.days, true)
val plain = source.insertTask(form("plain").copy(parentId = parent))
assertThat(source.subtaskIds(parent)).containsExactly(outOfWindow, series, plain)
@@ -162,6 +162,37 @@ class TasksDatabaseMigrationTest {
}
}
@Test
fun addsTheAdvancedOccurrencesTable() {
helper.createDatabase(TEST_DB, 4).close()
helper.runMigrationsAndValidate(TEST_DB, 5, true, TasksDatabase.MIGRATION_4_5).use { db ->
db.query("SELECT COUNT(*) FROM advanced_occurrences").use { c ->
assertThat(c.moveToFirst()).isTrue()
assertThat(c.getInt(0)).isEqualTo(0)
}
}
}
@Test
fun addsAnEmptyResetSubtasksRecord() {
helper.createDatabase(TEST_DB, 5).use { db ->
db.execSQL("INSERT INTO task_lists (id, name, color) VALUES (1, 'Personal', 0)")
db.execSQL("INSERT INTO tasks (id, list_id, uid) VALUES (1, 1, 'a'), (2, 1, 'b')")
db.execSQL(
"INSERT INTO advanced_occurrences (copy_id, master_id, anchor, advanced_to, prior_status, spent, from_override) " +
"VALUES (2, 1, 0, 1, 0, 1, 0)",
)
}
helper.runMigrationsAndValidate(TEST_DB, 6, true, TasksDatabase.MIGRATION_5_6).use { db ->
db.query("SELECT reset_subtasks FROM advanced_occurrences").use { c ->
assertThat(c.moveToFirst()).isTrue()
assertThat(c.getString(0)).isEmpty()
}
}
}
private companion object {
const val TEST_DB = "migration-test.db"
}
@@ -0,0 +1,90 @@
package de.jeanlucmakiola.agendula.data.sync
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.room.RoomTasksDataSource
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.Test
import org.junit.runner.RunWith
import kotlin.time.Clock
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
/** A server version replacing a moved series takes the moves' copies with it (#137). */
@RunWith(AndroidJUnit4::class)
class RoomSyncStoreAdvancesTest {
private val db = Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext<Context>(), TasksDatabase::class.java)
.allowMainThreadQueries().build()
@After
fun tearDown() = db.close()
@Test
fun droppingAdvancesDeletesTheirCopies() {
val list = db.taskLists().insert(TaskListEntity(name = "Home", color = 0))
val at = Instant.fromEpochSeconds(Clock.System.now().epochSeconds)
val series = db.tasks().insert(TaskEntity(listId = list, uid = "s", title = "Weekly", dtstart = at, rrule = "FREQ=WEEKLY"))
RoomTasksDataSource(db).setCompletedInstance(series, at, true)
val copy = db.tasks().allIn(list).single { it.id != series }
db.tasks().update(copy.copy(href = "copy.ics"))
RoomSyncStore(db).dropAdvances(series)
assertThat(db.advancedOccurrences().forMaster(series)).isEmpty()
assertThat(db.tasks().entity(copy.id)!!.isDeleted).isTrue()
}
@Test
fun movesTheServerAlreadyHasStay() {
val list = db.taskLists().insert(TaskListEntity(name = "Home", color = 0))
val at = Instant.fromEpochSeconds(Clock.System.now().epochSeconds)
val series = db.tasks().insert(TaskEntity(listId = list, uid = "s", title = "Weekly", dtstart = at, rrule = "FREQ=WEEKLY"))
RoomTasksDataSource(db).setCompletedInstance(series, at, true)
val copy = db.tasks().allIn(list).single { it.id != series }
val store = RoomSyncStore(db)
store.markSynced(listOf(db.tasks().entity(series)!!), "s.ics", "1")
store.dropAdvances(series)
assertThat(db.tasks().entity(copy.id)!!.isDeleted).isFalse()
}
@Test
fun aCopyGivenASubtaskSurvivesTheServerWinning() {
val list = db.taskLists().insert(TaskListEntity(name = "Home", color = 0))
val at = Instant.fromEpochSeconds(Clock.System.now().epochSeconds)
val series = db.tasks().insert(TaskEntity(listId = list, uid = "s", title = "Weekly", dtstart = at, rrule = "FREQ=WEEKLY"))
RoomTasksDataSource(db).setCompletedInstance(series, at, true)
val copy = db.tasks().allIn(list).single { it.id != series }
db.tasks().insert(TaskEntity(listId = list, uid = "p", title = "Part", parentId = copy.id))
RoomSyncStore(db).dropAdvances(series)
assertThat(db.tasks().entity(copy.id)).isNotNull()
assertThat(db.tasks().entity(copy.id)!!.isDeleted).isFalse()
}
@Test
fun aTickDuringTheUploadIsNotSettled() {
val list = db.taskLists().insert(TaskListEntity(name = "Home", color = 0))
val at = Instant.fromEpochSeconds(Clock.System.now().epochSeconds)
val series = db.tasks().insert(
TaskEntity(listId = list, uid = "s", title = "Weekly", dtstart = at, rrule = "FREQ=WEEKLY", lastModified = at - 1.seconds),
)
val uploaded = db.tasks().entity(series)!!
RoomTasksDataSource(db).setCompletedInstance(series, at, true)
val store = RoomSyncStore(db)
store.markSynced(listOf(uploaded), "s.ics", "1")
assertThat(db.tasks().entity(series)!!.isDirty).isTrue()
assertThat(db.advancedOccurrences().forMaster(series).single().settled).isFalse()
}
}
@@ -609,7 +609,7 @@ class CollectionSyncer(
// via `master_id`.
val (buried, kept) = local.rows.partition { it.isDeleted && it.recurrenceId != null }
if (buried.isNotEmpty()) store.deleteAll(buried.map { it.id })
store.markSynced(kept.map { it.id }, key, eTag)
store.markSynced(kept, key, eTag)
// Both, because a resource that failed as a create was counted under
// its UID and is now counted under its href. Clearing one would leak
// the other into the store forever.
@@ -825,6 +825,7 @@ class CollectionSyncer(
}
pendingDiscard.remove(href)?.let { cause ->
store.dropAdvances(masterId)
report = report.copy(
discardedEdits = report.discardedEdits +
DiscardedEdit(uid, losing?.title, cause),
@@ -3,6 +3,9 @@ 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 de.jeanlucmakiola.agendula.data.tasks.room.recurrenceAnchor
import de.jeanlucmakiola.agendula.data.tasks.wholeSecond
import kotlin.time.Duration.Companion.seconds
import javax.inject.Inject
/**
@@ -42,12 +45,21 @@ interface SyncStore {
fun deleteAll(taskIds: List<Long>)
/**
* Records href and ETag on a resource's rows, clearing `is_dirty`.
* Forgets how ticking occurrences moved [masterId]'s series, and deletes the
* copies that left: the server's version replaced the moved series, and its
* own overrides stand for those occurrences again. A row given a life of its
* own since stays.
*/
fun dropAdvances(masterId: Long) = Unit
/**
* Records href and ETag on a resource's [rows], as they were serialised,
* clearing `is_dirty` on those that did not change since.
*
* The caller excludes any row it left out of the body — that row is deleted,
* not marked synced.
*/
fun markSynced(taskIds: List<Long>, href: String?, eTag: String?)
fun markSynced(rows: List<TaskEntity>, href: String?, eTag: String?)
fun setParent(taskId: Long, parentId: Long?)
@@ -97,8 +109,42 @@ class RoomSyncStore @Inject constructor(
if (taskIds.isNotEmpty()) database.tasks().deleteAll(taskIds)
}
override fun markSynced(taskIds: List<Long>, href: String?, eTag: String?) {
if (taskIds.isNotEmpty()) database.tasks().markSynced(taskIds, href, eTag)
override fun dropAdvances(masterId: Long) = database.runInTransaction {
val advanced = database.advancedOccurrences()
val tasks = database.tasks()
val master = tasks.entity(masterId)
// Only moves the server never saw, on copies that are still just that.
advanced.forMaster(masterId).filter { !it.settled && it.undoable }.forEach { record ->
// An edited detached row was unlinked already, so only untouched ones are named.
val rows = advanced.detachedFor(record.copyId).mapNotNull { it.taskId } + record.copyId
advanced.delete(record.copyId)
rows.mapNotNull { tasks.entity(it) }
.filter { row ->
!row.isDeleted && tasks.subtasks(row.id).isEmpty() &&
(master == null || row.listId == master.listId && row.parentId == master.parentId)
}
.forEach { row ->
// Never uploaded: nobody to tell. Uploaded: a tombstone the next run deletes.
if (row.href == null) tasks.delete(row.id)
else tasks.update(row.copy(isDeleted = true, isDirty = true))
}
}
}
override fun markSynced(rows: List<TaskEntity>, href: String?, eTag: String?) {
if (rows.isEmpty()) return
database.runInTransaction {
database.tasks().markSynced(rows.map { it.id }, href, eTag)
// Changed while the body was on its way, by a tick or an edit: still owed to the server.
rows.forEach { database.tasks().dirtyIfChangedSince(it.id, it.lastModified) }
// The server now holds each series as far as the body moved it; a tick
// since is still the device's own.
rows.filter { it.recurrenceId == null }.forEach { row ->
val anchor = row.recurrenceAnchor
if (anchor == null) database.advancedOccurrences().settle(listOf(row.id))
else database.advancedOccurrences().settleUpTo(row.id, wholeSecond(anchor) + 1.seconds)
}
}
}
override fun setParent(taskId: Long, parentId: Long?) {
@@ -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, TasksDatabase.MIGRATION_2_3, TasksDatabase.MIGRATION_3_4)
.addMigrations(TasksDatabase.MIGRATION_1_2, TasksDatabase.MIGRATION_2_3, TasksDatabase.MIGRATION_3_4, TasksDatabase.MIGRATION_4_5, TasksDatabase.MIGRATION_5_6)
.build()
/**
@@ -7,9 +7,12 @@ import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.database.ContentObserver
import android.database.Cursor
import android.database.sqlite.SQLiteException
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.util.Log
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
@@ -22,6 +25,8 @@ import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.export.ExportTask
import de.jeanlucmakiola.agendula.domain.normalizeTags
import de.jeanlucmakiola.agendula.domain.statusFromInt
import de.jeanlucmakiola.agendula.domain.toInt
import de.jeanlucmakiola.agendula.domain.recurrence.ExpansionWindow
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
@@ -52,14 +57,47 @@ class AndroidTasksDataSource @Inject constructor(
// --- reads ----------------------------------------------------------------
override fun taskLists(): List<TaskList> {
val uri = TasksContract.listsUri(authority())
val sort = "${Lists.ACCOUNT_NAME}, ${Lists.NAME}"
return resolver.query(uri, TaskProjections.LISTS, null, null, sort)?.use { c ->
val authority = authority()
val lists = resolver.query(TasksContract.listsUri(authority), TaskProjections.LISTS, null, null, null)?.use { c ->
val reader = CursorColumnReader(c)
buildList { while (c.moveToNext()) add(TaskMapper.taskList(reader)) }
} ?: emptyList()
buildList<TaskList> { while (c.moveToNext()) add(TaskMapper.taskList(reader)) }
} ?: return emptyList()
val order = synchronized(listOrderLock) {
val stored = storedListOrder(authority)
val known = lists.mapTo(HashSet()) { it.id }
// A list gone from the provider takes its position along, as its id may be
// reused — but not on an empty answer, which is as likely a provider mid-update.
if (lists.isNotEmpty() && stored.keys.any { it !in known }) {
stored.filterKeys { it in known }.also { storeListOrder(authority, it) }
} else {
stored
}
}
return ListOrder.sorted(lists, order)
}
/** The order Agendula keeps for [authority]'s lists. */
internal fun storedListOrder(authority: String = authority()): Map<Long, Int> =
ListOrder.decode(listOrder.getString(authority, null))
private val listOrder get() = context.getSharedPreferences(LIST_ORDER, Context.MODE_PRIVATE)
private fun storeListOrder(authority: String, order: Map<Long, Int>) {
listOrder.edit().putString(authority, ListOrder.encode(order)).commit()
}
/** Kept by Agendula, as the provider's lists table has no order of its own. */
override fun reorderLists(listIds: List<Long>) {
val authority = authority()
synchronized(listOrderLock) {
storeListOrder(authority, ListOrder.reordered(taskLists().map { it.id }, listIds))
}
// Wakes the observers on the lists without asking any sync adapter to upload: nothing changed in the provider.
runCatching { resolver.notifyChange(TasksContract.listsUri(authority), null, 0) }
}
private val listOrderLock = Any()
override fun tasks(query: TaskQuery): List<Task> {
val clauses = mutableListOf<String>()
val args = mutableListOf<String>()
@@ -73,14 +111,22 @@ class AndroidTasksDataSource @Inject constructor(
}
override fun task(taskId: Long): Task? {
val rows = queryInstances("${Instances.TASK_ID} = ?", arrayOf(taskId.toString()))
return rows.firstOrNull { it.distanceFromCurrent == 0 } ?: rows.firstOrNull()
roundLegacyAnchors()
val rows = instanceRows(resolver.query(TasksContract.instancesUri(authority()), null, "${Instances.TASK_ID} = ?", arrayOf(taskId.toString()), null))
return (rows.firstOrNull { it.task.distanceFromCurrent == 0 } ?: rows.firstOrNull())?.let { enrich(listOf(it)).single() }
}
override fun task(taskId: Long, occurrenceStart: Instant?): Task? {
if (occurrenceStart == null) return task(taskId)
val rows = queryInstances("${Instances.TASK_ID} = ?", arrayOf(taskId.toString()))
return rows.firstOrNull { it.occurrenceStart == occurrenceStart } ?: task(taskId)
roundLegacyAnchors()
val rows = instanceRows(queryOccurrences(taskId))
val chosen = rows.firstOrNull { it.anchor == occurrenceStart }
?: rows.firstOrNull { it.anchor?.let { a -> sameSecond(a, occurrenceStart) } == true }
if (chosen != null) return enrich(listOf(chosen)).single()
tickedCopy(taskId, occurrenceStart)?.let { copy -> return task(copy)?.copy(occurrenceStart = occurrenceStart) }
// A plain task has one occurrence, whatever it was addressed by. A series
// answers null rather than another occurrence a write would then land on.
return if (seriesRules(listOf(taskId)).isEmpty()) task(taskId) else null
}
override fun subtasks(parentTaskId: Long): List<Task> =
@@ -91,39 +137,72 @@ class AndroidTasksDataSource @Inject constructor(
* 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()
override fun subtaskIds(parentTaskId: Long): List<Long> =
childRows(authority(), parentTaskId, projection = null) { it.getLong(Tasks.ID) }
/**
* All columns and no sort: the instances view differs across provider
* versions (tasks.org's has no `is_recurring`), the by-name mapper nulls what
* is missing, and the repository re-sorts.
*/
private fun queryInstances(selection: String?, args: Array<String>?): List<Task> {
val uri = TasksContract.instancesUri(authority())
// projection = null (all columns): the instances view differs across
// provider versions (e.g. tasks.org's bundled OpenTasks has no
// `is_recurring` / `distance_from_current`). The by-name mapper reads
// whatever is present and nulls the rest; the repository re-sorts, so we
// don't depend on a provider sort column either.
val tasks = resolver.query(uri, null, selection, args, null)
?.use { c ->
val reader = CursorColumnReader(c)
buildList { while (c.moveToNext()) add(TaskMapper.task(reader)) }
} ?: emptyList()
if (tasks.isEmpty()) return tasks
roundLegacyAnchors()
return instanceTasks(resolver.query(TasksContract.instancesUri(authority()), null, selection, args, null))
}
/** Maps [cursor]'s instance rows, with the rule each series keeps on its `tasks` row, and their tags. */
private fun instanceTasks(cursor: Cursor?): List<Task> = enrich(instanceRows(cursor))
/** [cursor]'s instance rows, before their rules and tags are looked up. */
private fun instanceRows(cursor: Cursor?): List<InstanceRow> {
var flagged = false
val rows = cursor?.use { c ->
flagged = c.getColumnIndex(Instances.IS_RECURRING) >= 0
val reader = CursorColumnReader(c)
buildList { while (c.moveToNext()) add(TaskMapper.instance(reader)) }
}.orEmpty()
// Where the provider flags recurrence, only flagged rows need their rule looked up.
return if (flagged) rows.map { if (it.task.isRecurring) it else it.copy(ruleOwner = NO_RULE) } else rows
}
/** [rows] completed with the rule each series keeps on its `tasks` row, and their tags. */
private fun enrich(rows: List<InstanceRow>): List<Task> {
if (rows.isEmpty()) return emptyList()
val rules = seriesRules(rows.map { it.ruleOwner }.filter { it != NO_RULE })
val tasks = rows.map { TaskMapper.withRule(it, rules[it.ruleOwner]) }
val tags = categories(tasks.map { it.taskId })
return tasks.map { task -> tags[task.taskId]?.let { task.copy(tags = it) } ?: task }
}
/** The recurrence of each of [taskIds] that is a series, keyed by task id. */
private fun seriesRules(taskIds: Collection<Long>): Map<Long, SeriesRule> = queryByIds(
TasksContract.tasksUri(authority()),
arrayOf(Tasks.ID, Tasks.RRULE, Tasks.RDATE),
Tasks.ID,
taskIds,
filter = "${Tasks.RRULE} IS NOT NULL OR ${Tasks.RDATE} IS NOT NULL",
) { r -> r.getLong(Tasks.ID)?.let { id -> TaskMapper.seriesRule(r)?.let { id to it } } }.toMap()
/**
* Rows of [uri] whose [idColumn] is one of [ids], in chunks that stay under
* SQLite's bound-argument limit, each mapped by [read] (null rows dropped).
*/
private fun <T : Any> queryByIds(
uri: Uri,
projection: Array<String>,
idColumn: String,
ids: Collection<Long>,
filter: String? = null,
filterArgs: List<String> = emptyList(),
sortOrder: String? = null,
read: (ColumnReader) -> T?,
): List<T> = inSelections(idColumn, ids, filter, filterArgs).flatMap { (selection, args) ->
resolver.query(uri, projection, selection, args, sortOrder)?.use { c ->
val reader = CursorColumnReader(c)
buildList { while (c.moveToNext()) read(reader)?.let(::add) }
} ?: emptyList()
}
override fun exportTasks(listId: Long): List<ExportTask> {
// projection = null for the same reason queryInstances uses it: the tasks
// table's shape varies across provider versions, and the by-name mapper
@@ -194,7 +273,7 @@ class AndroidTasksDataSource @Inject constructor(
}
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) {
val occurrence = task(seriesId, occurrenceStart)
val occurrence = occurrence(seriesId, occurrenceStart) { TaskMapper.instance(it).task } ?: task(seriesId)
val anchors = resolver.query(
taskUri(authority(), seriesId), arrayOf(Tasks.DTSTART, Tasks.DUE), null, null, null,
)?.use { c ->
@@ -202,14 +281,14 @@ class AndroidTasksDataSource @Inject constructor(
val r = CursorColumnReader(c)
r.getLong(Tasks.DTSTART) to r.getLong(Tasks.DUE)
}
fun shifted(anchor: Long?, was: Instant?, now: Instant?): Long? = when {
now == null -> null
anchor == null || was == null -> now.toEpochMilliseconds()
else -> anchor + (now - was).inWholeMilliseconds
}
val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id) +
(Tasks.DTSTART to shifted(anchors?.first, occurrence?.start, form.start)) +
(Tasks.DUE to shifted(anchors?.second, occurrence?.due, form.due))
val base = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id)
// Without an anchor to shift, the form's own value stands — already pinned for an all-day series.
fun shifted(column: String, anchor: Long?, was: Instant?, now: Instant?): Any? =
if (now == null || anchor == null || was == null) base[column]
else providerMillis(anchor + (now - was).inWholeMilliseconds)
val values = base +
(Tasks.DTSTART to shifted(Tasks.DTSTART, anchors?.first, occurrence?.start, form.start)) +
(Tasks.DUE to shifted(Tasks.DUE, anchors?.second, occurrence?.due, form.due))
val rows = resolver.update(taskUri(authority(), seriesId), values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("update series $seriesId")
setCategories(seriesId, form.tags)
@@ -250,17 +329,7 @@ class AndroidTasksDataSource @Inject constructor(
arrayOf(Tasks.RRULE, Tasks.RDATE, Tasks.EXDATE, Tasks.DTSTART, Tasks.DUE, Tasks.IS_ALLDAY, Tasks.TZ),
null, null, null,
)?.use { c ->
if (!c.moveToFirst()) return@use null
val r = CursorColumnReader(c)
val anchor = r.getLong(Tasks.DTSTART) ?: r.getLong(Tasks.DUE) ?: return@use null
RecurrenceSpec(
rrule = r.getString(Tasks.RRULE)?.ifBlank { null },
rdate = r.getString(Tasks.RDATE),
exdate = r.getString(Tasks.EXDATE),
anchor = Instant.fromEpochMilliseconds(anchor),
isAllDay = r.getBoolean(Tasks.IS_ALLDAY),
timeZone = r.getString(Tasks.TZ),
)
if (c.moveToFirst()) TaskMapper.seriesSpec(CursorColumnReader(c)) else null
}
/** End the master's rule before [occurrenceStart], and drop the exceptions past it. */
@@ -290,7 +359,7 @@ class AndroidTasksDataSource @Inject constructor(
buildList {
while (c.moveToNext()) {
val original = r.getLong(Tasks.ORIGINAL_INSTANCE_TIME) ?: continue
if (original >= from.toEpochMilliseconds()) r.getLong(Tasks.ID)?.let(::add)
if (providerMillis(original) >= providerMillis(from.toEpochMilliseconds())) r.getLong(Tasks.ID)?.let(::add)
}
}
} ?: emptyList()
@@ -302,7 +371,10 @@ class AndroidTasksDataSource @Inject constructor(
}
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) {
if (reminders == null) return writeInstance(taskId, occurrenceStart, form)
val hit = instanceFor(taskId, occurrenceStart)
?: throw TaskWriteFailedException("update instance $taskId@$occurrenceStart: no such occurrence")
hit.overrideId?.let { return updateOverride(it, form, reminders) }
if (reminders == null) return writeInstance(taskId, hit.instanceId, 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
@@ -311,15 +383,26 @@ class AndroidTasksDataSource @Inject constructor(
val seriesReminders = alarmRows(taskId).map { it.second }
writeReminders(taskId, reminders)
try {
writeInstance(taskId, occurrenceStart, form)
writeInstance(taskId, hit.instanceId, 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")
/**
* An occurrence that already has its own row is written there directly. The
* fork-and-restore dance in [updateInstance] would put its old tags and
* reminders back over the new ones.
*/
private fun updateOverride(overrideId: Long, form: TaskForm, reminders: List<TaskReminder>?) {
val values = TaskWriteMapper.instanceValues(form, ZoneId.systemDefault().id)
val rows = resolver.update(taskUri(authority(), overrideId), values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("update override $overrideId")
setCategories(overrideId, form.tags)
writeReminders(overrideId, reminders)
}
private fun writeInstance(taskId: Long, instanceId: Long, form: TaskForm) {
val values = TaskWriteMapper.instanceValues(form, ZoneId.systemDefault().id)
val uri = TasksContract.instanceUri(authority(), instanceId)
// The fork copies the series' properties onto the override, so the tags
@@ -346,28 +429,147 @@ class AndroidTasksDataSource @Inject constructor(
}
}
/** An occurrence's instance row, and the override row behind it if it has one. */
private class InstanceHit(val instanceId: Long, val overrideId: Long?)
/**
* The provider's instance row id for one occurrence.
* The provider's instance row for one occurrence.
*
* The seam addresses occurrences by `(taskId, occurrenceStart)`; writing
* through the instances URI still needs the row id, so it is looked up here
* rather than carried around above the data layer. Selection is on `task_id`
* only — the anchor is matched in Kotlin because the column that holds it
* (`instance_original_time`) is missing on older provider schemas, where a
* WHERE clause naming it would throw instead of falling back.
* rather than carried around above the data layer.
*/
private fun instanceIdFor(taskId: Long, occurrenceStart: Instant): Long? {
val uri = TasksContract.instancesUri(authority())
val selection = "${Instances.TASK_ID} = ?"
return resolver.query(uri, null, selection, arrayOf(taskId.toString()), null)?.use { c ->
private fun instanceFor(taskId: Long, occurrenceStart: Instant): InstanceHit? {
roundLegacyAnchors()
return occurrence(taskId, occurrenceStart) { r ->
r.getLong(Tasks.ID)?.let { id ->
InstanceHit(id, r.getLong(Instances.TASK_ID)?.takeIf { r.getLong(Tasks.ORIGINAL_INSTANCE_ID) != null })
}
}
}
private fun instanceIdFor(taskId: Long, occurrenceStart: Instant): Long? = instanceFor(taskId, occurrenceStart)?.instanceId
/**
* [read] applied to the instance row of [taskId]'s occurrence at
* [occurrenceStart] — its own, or an override's that replaced it — matched in
* whole seconds, as the provider expands them. The anchor is matched in
* Kotlin because `instance_original_time` is missing on older schemas.
*/
private fun <T> occurrence(taskId: Long, occurrenceStart: Instant, read: (ColumnReader) -> T): T? =
queryOccurrences(taskId)?.use { c ->
val reader = CursorColumnReader(c)
// An exact match first: a row of the same second is a fallback, not a peer.
var near: Int? = null
while (c.moveToNext()) {
if (TaskMapper.occurrenceAnchor(reader) == occurrenceStart) {
return@use reader.getLong(Tasks.ID)
val anchor = TaskMapper.occurrenceAnchor(reader) ?: continue
if (anchor == occurrenceStart) return@use read(reader)
if (near == null && sameSecond(anchor, occurrenceStart)) near = c.position
}
near?.let { c.moveToPosition(it); read(reader) }
}
/**
* Instance rows of [taskId] and of the overrides standing in for its
* occurrences. A provider whose view lacks `original_instance_id` gets the
* task's own rows only, rather than a failed query.
*/
private fun queryOccurrences(taskId: Long): Cursor? {
val authority = authority()
val uri = TasksContract.instancesUri(authority)
val id = taskId.toString()
if (ownRowsOnlyFor != authority) {
try {
return resolver.query(uri, null, OCCURRENCE_OF, arrayOf(id, id), null)
} catch (e: SQLiteException) {
rememberIfMissingColumn(authority, e)
} catch (e: IllegalArgumentException) {
rememberIfMissingColumn(authority, e)
}
}
return resolver.query(uri, null, "${Instances.TASK_ID} = ?", arrayOf(id), null)
}
/** Only a view without the column is remembered; any other failure falls back for this query alone. */
private fun rememberIfMissingColumn(authority: String, e: Exception) {
val message = e.message.orEmpty()
if (Tasks.ORIGINAL_INSTANCE_ID in message || "no such column" in message || "Invalid column" in message) {
ownRowsOnlyFor = authority
} else {
Log.w(TAG, "Occurrence query failed, reading the task's own rows", e)
}
}
/** The provider whose instances view turned out to lack `original_instance_id`. */
@Volatile
private var ownRowsOnlyFor: String? = null
@Volatile
private var anchorsRoundedFor: String? = null
private val roundingLock = Any()
/**
* Rounds the DTSTART/DUE a build before [providerMillis] stored with
* milliseconds, which the provider cannot move past a completed occurrence.
* Once per provider, before the first read: concurrent readers wait for it.
* Written as the rows' sync adapter, since the server only ever had whole
* seconds, so nothing is dirtied or re-uploaded. Overrides need nothing: the
* provider forks them at its own whole-second instance times.
*
* Never fails a read. A row the provider refuses is left as it was and tried
* again on the next start; only a pass that rounded everything is remembered.
*/
private fun roundLegacyAnchors() {
val authority = authority()
if (anchorsRoundedFor == authority) return
synchronized(roundingLock) {
if (anchorsRoundedFor == authority) return
val prefs = context.getSharedPreferences(MIGRATIONS, Context.MODE_PRIVATE)
val key = "anchors_rounded:$authority"
val done = prefs.getBoolean(key, false) || runCatching { roundAll(authority) }
.onFailure { Log.w(TAG, "Rounding legacy anchors failed", it) }
.getOrDefault(false)
if (done) prefs.edit().putBoolean(key, true).apply()
anchorsRoundedFor = authority
}
}
/** True when every stale row was rounded. */
private fun roundAll(authority: String): Boolean {
val columns = listOf(Tasks.DTSTART, Tasks.DUE)
val ops = resolver.query(
TasksContract.tasksUri(authority),
arrayOf(Tasks.ID, Tasks.ACCOUNT_NAME, Tasks.ACCOUNT_TYPE) + columns,
// Only series: the provider moves nothing else, so nothing else needs rounding.
"(${columns.joinToString(" OR ") { "$it % 1000 != 0" }}) AND (${Tasks.RRULE} IS NOT NULL OR ${Tasks.RDATE} IS NOT NULL)",
null,
null,
)?.use { c ->
val r = CursorColumnReader(c)
buildList<ContentProviderOperation> {
while (c.moveToNext()) {
val id = r.getLong(Tasks.ID) ?: continue
val values = buildMap<String, Any?> {
for (column in columns) {
val millis = r.getLong(column) ?: continue
if (providerMillis(millis) != millis) put(column, providerMillis(millis))
}
}
val name = r.getString(Tasks.ACCOUNT_NAME)
val type = r.getString(Tasks.ACCOUNT_TYPE)
val uri = if (name.isNullOrEmpty() || type.isNullOrEmpty()) taskUri(authority, id)
else TasksContract.asSyncAdapter(taskUri(authority, id), name, type)
add(ContentProviderOperation.newUpdate(uri).withValues(values.toContentValues()).build())
}
}
null
}
} ?: return false
if (ops.isEmpty()) return true
val batch = runCatching { resolver.applyBatch(authority, ArrayList(ops)) }.getOrNull()
if (batch != null) return batch.all { (it.count ?: 0) > 0 }
// One refused row fails the whole batch; row by row, the rest still go.
return ops.map { op -> runCatching { resolver.applyBatch(authority, arrayListOf(op)).single().count ?: 0 }.getOrDefault(0) > 0 }
.all { it }
}
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) {
@@ -430,29 +632,19 @@ class AndroidTasksDataSource @Inject constructor(
}
/** Tags by task id for [taskIds], in stored order. */
private fun categories(taskIds: Collection<Long>): Map<Long, List<String>> {
val rows = taskIds.distinct().chunked(MAX_SQL_ARGS - 1).flatMap { chunk ->
val selection = "${Properties.MIMETYPE} = ? AND ${Properties.TASK_ID} IN (${chunk.joinToString(",") { "?" }})"
val args = (listOf(TasksContract.Category.MIMETYPE) + chunk.map(Long::toString)).toTypedArray()
resolver.query(
TasksContract.propertiesUri(authority()),
arrayOf(Properties.TASK_ID, TasksContract.Category.NAME),
selection,
args,
Properties.PROPERTY_ID,
)?.use { c ->
val reader = CursorColumnReader(c)
buildList {
while (c.moveToNext()) {
val id = reader.getLong(Properties.TASK_ID) ?: continue
val name = reader.getString(TasksContract.Category.NAME) ?: continue
add(id to name)
}
}
} ?: emptyList()
}
return rows.groupBy({ it.first }, { it.second }).mapValues { normalizeTags(it.value) }
}
private fun categories(taskIds: Collection<Long>): Map<Long, List<String>> = queryByIds(
TasksContract.propertiesUri(authority()),
arrayOf(Properties.TASK_ID, TasksContract.Category.NAME),
Properties.TASK_ID,
taskIds,
filter = "${Properties.MIMETYPE} = ?",
filterArgs = listOf(TasksContract.Category.MIMETYPE),
sortOrder = Properties.PROPERTY_ID,
) { r ->
val id = r.getLong(Properties.TASK_ID)
val name = r.getString(TasksContract.Category.NAME)
if (id == null || name == null) null else id to name
}.groupBy({ it.first }, { it.second }).mapValues { normalizeTags(it.value) }
/**
* Replaces [taskId]'s category rows in one batch, so a refused insert cannot
@@ -482,16 +674,21 @@ class AndroidTasksDataSource @Inject constructor(
}
override fun setCancelled(taskId: Long, cancelled: Boolean) {
if (!cancelled && movedBack(taskId, null)) return
val rows = resolver.update(taskUri(authority(), taskId), cancelValues(cancelled).toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("cancel task $taskId")
}
/** Through the instances URI, like [setCompletedInstance]. */
override fun setCancelledInstance(taskId: Long, occurrenceStart: Instant, cancelled: Boolean) {
if (!cancelled && movedBack(taskId, occurrenceStart)) return
val instanceId = instanceIdFor(taskId, occurrenceStart)
?: return setCancelled(taskId, cancelled)
?: return tickedCopy(taskId, occurrenceStart)?.let { setCancelled(it, cancelled) }
?: plainUnlessSeries(taskId, occurrenceStart) { setCancelled(taskId, cancelled) }
val uri = TasksContract.instanceUri(authority(), instanceId)
val rows = resolver.update(uri, cancelValues(cancelled).toContentValues(), null, null)
val rows = rememberingCopy(taskId, occurrenceStart, closing = cancelled) {
resolver.update(uri, cancelValues(cancelled).toContentValues(), null, null)
}
if (rows == 0) throw TaskWriteFailedException("cancel instance $instanceId")
}
@@ -501,6 +698,7 @@ class AndroidTasksDataSource @Inject constructor(
)
override fun setCompleted(taskId: Long, completed: Boolean) {
if (!completed && movedBack(taskId, null)) return
val values = TaskWriteMapper.completionValues(completed, System.currentTimeMillis())
val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("complete task $taskId")
@@ -508,22 +706,287 @@ class AndroidTasksDataSource @Inject constructor(
/**
* Through the instances URI, which is what makes the provider fork an override
* rather than close the series. No instance row for the anchor means the task
* is not a series after all — the plain write is then the right one.
* rather than close the series. No instance row for the anchor on a task that
* is not a series means the plain write is the right one.
*/
override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) {
if (!completed && movedBack(taskId, occurrenceStart)) return
val instanceId = instanceIdFor(taskId, occurrenceStart)
?: return setCompleted(taskId, completed)
?: return tickedCopy(taskId, occurrenceStart)?.let { setCompleted(it, completed) }
?: plainUnlessSeries(taskId, occurrenceStart) { setCompleted(taskId, completed) }
val values = TaskWriteMapper.completionValues(completed, System.currentTimeMillis())
val uri = TasksContract.instanceUri(authority(), instanceId)
val rows = resolver.update(uri, values.toContentValues(), null, null)
val rows = rememberingCopy(taskId, occurrenceStart, closing = completed) {
resolver.update(uri, values.toContentValues(), null, null)
}
if (rows == 0) throw TaskWriteFailedException("complete instance $instanceId")
}
/**
* Runs [write], and when it closed an occurrence the provider then detached
* into a task of its own, remembers that copy, so the occurrence can still be
* read and reopened, and how the series moved, so reopening can move it back.
* When the series moved on, its sub-tasks start over for the next occurrence,
* and [resets] remembers how they were (#160), with or without a copy.
*/
private fun rememberingCopy(taskId: Long, occurrenceStart: Instant, closing: Boolean, write: () -> Int): Int {
if (!closing) return write()
val authority = authority()
val lastId = runCatching { lastTaskId(authority) }.getOrNull() ?: return write()
val before = runCatching { seriesState(authority, taskId) }.getOrNull()
return write().also { rows ->
if (rows > 0) {
val after = before?.let {
runCatching { seriesState(authority, taskId) }
.onFailure { Log.w(TAG, "Reading the series after the tick failed", it) }
.getOrNull()?.takeIf { it != before }
}
if (before != null && after != null && SubtaskReset.movedOn(before, after)) {
runCatching { resetSubtasks(authority, taskId, before, after) }
.onFailure { Log.w(TAG, "Resetting the sub-tasks failed", it) }
}
runCatching {
detachedSince(authority, lastId)?.let { copy ->
markCopy(authority, copy, taskId)
val tick = Tick(copy, taskId.takeIf { after != null }, before.takeIf { after != null }, after, System.currentTimeMillis())
ticks.put(ticks.key(authority, taskId, occurrenceStart), tick)
}
}.onFailure { Log.w(TAG, "Looking up the detached copy failed", it) }
}
}
}
/**
* Marks [copyId], left by ticking the recurring sub-task [seriesId], with the
* UID of the parent it sits under, where DAVx5 keeps and uploads unknown
* properties, so other installs know it too. A top-level series' copy sits
* under nothing a reset walks, so it is left unmarked.
*/
private fun markCopy(authority: String, copyId: Long, seriesId: Long) {
runCatching {
val parentId = resolver.query(taskUri(authority, seriesId), arrayOf(Tasks.PARENT_ID), null, null, null)
?.use { c -> if (c.moveToFirst() && !c.isNull(0)) c.getLong(0) else null } ?: return
val parentUid = uidOf(authority, parentId) ?: return
val values = mapOf(
Properties.TASK_ID to copyId,
Properties.MIMETYPE to SubtaskReset.UNKNOWN_PROPERTY_MIMETYPE,
Properties.DATA0 to SubtaskReset.copyMarkerJson(parentUid),
)
resolver.insert(TasksContract.propertiesUri(authority), values.toContentValues())
}.onFailure { Log.w(TAG, "Marking the ticked copy failed", it) }
}
private fun uidOf(authority: String, taskId: Long): String? =
resolver.query(taskUri(authority, taskId), arrayOf(Tasks.UID), null, null, null)
?.use { c -> if (c.moveToFirst()) c.getString(0) else null }
/** The parent UID each of [taskIds] carries a copy marker for. */
private fun markerParents(authority: String, taskIds: List<Long>): Map<Long, String> = runCatching {
queryByIds(
TasksContract.propertiesUri(authority),
arrayOf(Properties.TASK_ID, Properties.DATA0),
Properties.TASK_ID,
taskIds,
filter = "${Properties.MIMETYPE} = ?",
filterArgs = listOf(SubtaskReset.UNKNOWN_PROPERTY_MIMETYPE),
) { r ->
val id = r.getLong(Properties.TASK_ID) ?: return@queryByIds null
SubtaskReset.markerParentFromJson(r.getString(Properties.DATA0))?.let { id to it }
}.toMap()
}.onFailure { Log.w(TAG, "Reading the copy markers failed", it) }.getOrDefault(emptyMap())
/**
* Resets what [SubtaskReset] picks under the series [seriesId], dated ones
* moved as the series moved from [before] to [after], and records it in
* [resets] first, under the anchor the series moved to.
*/
private fun resetSubtasks(authority: String, seriesId: Long, before: Map<String, Any?>, after: Map<String, Any?>) {
val copies = ticks.copies(authority)
val uids = HashMap<Long, String?>().apply { put(seriesId, uidOf(authority, seriesId)) }
val reset = SubtaskReset.toReset(seriesId, SubtaskReset.shiftOf(before, after)) { id ->
val rows = childRows(authority, id, SUBTASK_STATE) { r ->
r.getLong(Tasks.ID)?.let { rowId ->
uids[rowId] = r.getString(Tasks.UID)
// Read now: the cursor is closed by the time the markers are in.
val rrule = r.getString(Tasks.RRULE)
val rdate = r.getString(Tasks.RDATE)
val state = subtaskState(r)
val dates = SubtaskDates(r.getLong(Tasks.DTSTART), r.getLong(Tasks.DUE))
val allDay = r.getBoolean(Tasks.IS_ALLDAY)
val tz = r.getString(Tasks.TZ)
rowId to { markerParent: String? ->
SubtaskReset.providerNode(
rowId, rrule, rdate, copies, markerParent = markerParent, parentUid = uids[id],
state = state, dates = dates, isAllDay = allDay, timeZone = tz,
)
}
}
}
val markers = markerParents(authority, rows.map { it.first })
rows.map { (rowId, node) -> node(markers[rowId]) }
}
if (reset.isEmpty()) return
SubtaskReset.anchor(after)?.let { resets.put(authority, seriesId, it, SubtaskReset.encode(reset), System.currentTimeMillis()) }
val ops = reset.map { subtaskOp(authority, it.id, SubtaskState.FRESH, it.dates?.takeIf { d -> d != it.priorDates }) }
resolver.applyBatch(authority, ArrayList(ops))
}
private fun subtaskState(r: ColumnReader) =
SubtaskState(statusFromInt(r.getInt(Tasks.STATUS)), r.getInt(Tasks.PERCENT_COMPLETE), r.getLong(Tasks.COMPLETED))
/** [state] onto sub-task [id], and [dates] when they change. */
private fun subtaskOp(authority: String, id: Long, state: SubtaskState, dates: SubtaskDates?): ContentProviderOperation {
val values = buildMap<String, Any?> {
put(Tasks.STATUS, state.status.toInt())
put(Tasks.PERCENT_COMPLETE, state.percent)
put(Tasks.COMPLETED, state.completedAt)
if (dates != null) {
put(Tasks.DTSTART, dates.start)
put(Tasks.DUE, dates.due)
}
}
return ContentProviderOperation.newUpdate(taskUri(authority, id)).withValues(values.toContentValues()).build()
}
/** The reset sub-tasks of [reset] that [SubtaskReset.toRestore] gives back their state and dates. */
private fun subtaskRestore(authority: String, reset: String?): List<ContentProviderOperation> {
val entries = SubtaskReset.decode(reset)
if (entries.isEmpty()) return emptyList()
val current = queryByIds(
TasksContract.tasksUri(authority),
SUBTASK_STATE,
Tasks.ID,
entries.map { it.id },
filter = "${Tasks.DELETED} IS NULL OR ${Tasks.DELETED} = 0",
) { r ->
r.getLong(Tasks.ID)?.let {
it to SubtaskNow(subtaskState(r), SubtaskDates(r.getLong(Tasks.DTSTART), r.getLong(Tasks.DUE)), r.getBoolean(Tasks.IS_ALLDAY))
}
}.toMap()
val outcome = SubtaskReset.toRestore(entries, current::get)
if (outcome.changed.isNotEmpty()) Log.i(TAG, "Sub-tasks ${outcome.changed} changed since the reset and keep their state")
return outcome.restore.map { subtaskOp(authority, it.id, it.prior, it.priorDates?.takeIf { d -> d != it.dates }) }
}
/** Walks [parentId]'s direct sub-task rows, overrides left out, with [read] on each. */
private fun <T : Any> childRows(authority: String, parentId: Long, projection: Array<String>?, read: (ColumnReader) -> T?): List<T> =
resolver.query(
TasksContract.tasksUri(authority),
projection,
"${Tasks.PARENT_ID} = ? AND (${Tasks.DELETED} IS NULL OR ${Tasks.DELETED} = 0)",
arrayOf(parentId.toString()),
null,
)?.use { c ->
val r = CursorColumnReader(c)
buildList {
while (c.moveToNext()) {
if (r.getLong(Tasks.ORIGINAL_INSTANCE_ID) != null || r.getLong(Tasks.ID) == parentId) continue
read(r)?.let(::add)
}
}
} ?: emptyList()
/**
* Undoes the tick that left [taskId]'s occurrence at [occurrenceStart] — or,
* with no occurrence, the tick that left the copy [taskId]: the series goes
* back where it was and the copy goes, as long as the series is still where
* the tick moved it and the copy has nothing filed under it. The sub-tasks
* the tick reset get their state back from [resets]. False when it cannot,
* so the caller reopens the copy as a task of its own.
*/
private fun movedBack(taskId: Long, occurrenceStart: Instant?): Boolean = runCatching {
val authority = authority()
val (key, tick) = occurrenceStart?.let { at -> ticks.key(authority, taskId, at).let { k -> ticks.get(k)?.let { k to it } } }
?: ticks.forCopy(authority, taskId)
?: return false
val series = tick.seriesId ?: return false
val before = tick.before ?: return false
val after = tick.after ?: return false
// Compared on what the tick recorded: one from before IS_ALLDAY and TZ were read has neither.
val now = seriesState(authority, series) ?: return false
if (after.any { (column, value) -> now[column] != value } || subtaskIds(tick.copyId).isNotEmpty()) return false
val restore = before.filter { (column, value) -> after[column] != value }
val ops = arrayListOf(
ContentProviderOperation.newUpdate(taskUri(authority, series)).withValues(restore.toContentValues()).build(),
ContentProviderOperation.newDelete(taskUri(authority, tick.copyId)).build(),
)
val anchor = SubtaskReset.anchor(after)
anchor?.let { at ->
runCatching { ops += subtaskRestore(authority, resets.get(authority, series, at)) }
.onFailure { Log.w(TAG, "Reading the reset sub-tasks failed", it) }
}
resolver.applyBatch(authority, ops)
ticks.remove(key)
anchor?.let { resets.remove(authority, series, it) }
true
}.onFailure { Log.w(TAG, "Moving the series back failed", it) }.getOrDefault(false)
/** The columns a tick moves a series by, or null when [taskId] is no series. */
private fun seriesState(authority: String, taskId: Long): Map<String, Any?>? =
resolver.query(taskUri(authority, taskId), SERIES_STATE, null, null, null)?.use { c ->
if (!c.moveToFirst()) return@use null
SERIES_STATE.indices.associate { i ->
SERIES_STATE[i] to when (c.getType(i)) {
Cursor.FIELD_TYPE_NULL -> null
Cursor.FIELD_TYPE_INTEGER -> c.getLong(i)
else -> c.getString(i)
}
}
}?.takeIf { state -> listOf(Tasks.RRULE, Tasks.RDATE).any { !(state[it] as? String).isNullOrBlank() } }
private fun lastTaskId(authority: String): Long =
resolver.query(TasksContract.tasksUri(authority), arrayOf(Tasks.ID), null, null, "${Tasks.ID} DESC")
?.use { c -> if (c.moveToFirst()) c.getLong(0) else 0L } ?: 0L
/** The one plain task the provider created after [afterId], or null when it was not exactly one. */
private fun detachedSince(authority: String, afterId: Long): Long? =
resolver.query(TasksContract.tasksUri(authority), null, "${Tasks.ID} > ?", arrayOf(afterId.toString()), null)
?.use { c ->
val r = CursorColumnReader(c)
buildList {
while (c.moveToNext()) {
val plain = r.getString(Tasks.RRULE) == null && r.getString(Tasks.RDATE) == null &&
r.getLong(Tasks.ORIGINAL_INSTANCE_ID) == null
if (plain) r.getLong(Tasks.ID)?.let(::add)
}
}
}?.singleOrNull()
private val ticks = TickedCopies(
prefs = { context.getSharedPreferences(TICKED_COPIES, Context.MODE_PRIVATE) },
known = { context.getSharedPreferences(KNOWN_COPIES, Context.MODE_PRIVATE) },
)
private val resets = TickResets { context.getSharedPreferences(TICK_RESETS, Context.MODE_PRIVATE) }
/** The copy a tick left for [taskId]'s occurrence at [occurrenceStart], if one was remembered. */
private fun tickedCopy(taskId: Long, occurrenceStart: Instant): Long? =
ticks.get(ticks.key(authority(), taskId, occurrenceStart))?.copyId
/**
* [write] for a task that turned out not to be a series. A series whose
* occurrence was not found fails instead: the plain write would close all of it.
*/
private fun plainUnlessSeries(taskId: Long, occurrenceStart: Instant, write: () -> Unit) {
if (seriesRules(listOf(taskId)).isNotEmpty()) {
throw TaskWriteFailedException("instance $taskId@$occurrenceStart: no such occurrence")
}
write()
}
override fun deleteTask(taskId: Long) {
resolver.delete(taskUri(authority(), taskId), null, null)
}
/**
* Through its instance, which the provider turns into an EXDATE on the master,
* as the Room store does. Deleting the row alone leaves the master without one.
*/
override fun deleteOverride(taskId: Long, occurrenceStart: Instant) {
val instanceId = instanceIdFor(taskId, occurrenceStart) ?: return deleteTask(taskId)
resolver.delete(TasksContract.instanceUri(authority(), instanceId), null, null)
}
override fun createLocalList(name: String, color: Int): Long {
val uri = TasksContract.asSyncAdapter(
TasksContract.listsUri(authority()),
@@ -594,5 +1057,35 @@ class AndroidTasksDataSource @Inject constructor(
}
}
/** SQLite's default bound-argument limit on older Android releases. */
private const val MAX_SQL_ARGS = 999
private const val LIST_ORDER = "provider_list_order"
/** An instance row of the task, or of an override standing in for one of its occurrences. */
private const val OCCURRENCE_OF = "${Instances.TASK_ID} = ? OR ${Tasks.ORIGINAL_INSTANCE_ID} = ?"
private const val MIGRATIONS = "provider_migrations"
private const val TICKED_COPIES = "provider_ticked_copies"
/** Every copy a tick on this install left, kept past [TickedCopies.HORIZON_MS] alongside the marker on the row. */
private const val KNOWN_COPIES = "provider_known_copies"
/** The sub-tasks each tick reset, kept apart from its copy. */
private const val TICK_RESETS = "provider_tick_resets"
/** What resetting a sub-task reads: whether it recurs, its progress and its dates. */
private val SUBTASK_STATE = arrayOf(
Tasks.ID, Tasks.ORIGINAL_INSTANCE_ID, Tasks.RRULE, Tasks.RDATE, Tasks.STATUS, Tasks.PERCENT_COMPLETE, Tasks.COMPLETED,
Tasks.DTSTART, Tasks.DUE, Tasks.IS_ALLDAY, Tasks.TZ, Tasks.UID,
)
/** What a tick changes on a series, and an undo changes back; with what its rule is read in. */
private val SERIES_STATE = arrayOf(
Tasks.DTSTART, Tasks.DUE, Tasks.DURATION, Tasks.RRULE, Tasks.RDATE, Tasks.EXDATE,
Tasks.STATUS, Tasks.COMPLETED, Tasks.PERCENT_COMPLETE, Tasks.IS_ALLDAY, Tasks.TZ,
)
private const val TAG = "AndroidTasksDataSource"
/** No rule to look up: the provider says the row does not recur. */
private const val NO_RULE = -1L
@@ -0,0 +1,36 @@
package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.domain.TaskList
/**
* Agendula's own order for an external provider's lists, which the provider has
* no column for. Stored as `id=position` pairs. A list without one counts as
* position 0, as a new list does in Agendula's own store.
*/
object ListOrder {
fun decode(raw: String?): Map<Long, Int> = raw.orEmpty().split(',').mapNotNull { pair ->
val (id, position) = pair.split('=').takeIf { it.size == 2 } ?: return@mapNotNull null
id.toLongOrNull()?.let { key -> position.toIntOrNull()?.let { key to it } }
}.toMap()
fun encode(order: Map<Long, Int>): String = order.entries.joinToString(",") { "${it.key}=${it.value}" }
/**
* Positions for every list in [shown] — the lists in their current order —
* after one account's [listIds] were rearranged: those take each other's
* slots in their new order, and every other list keeps its own.
*/
fun reordered(shown: List<Long>, listIds: List<Long>): Map<Long, Int> {
val moved = listIds.iterator()
val moving = listIds.toSet()
val result = shown.map { if (it in moving && moved.hasNext()) moved.next() else it }.toMutableList()
moved.forEachRemaining { if (it !in result) result += it }
return result.withIndex().associate { it.value to it.index }
}
fun sorted(lists: List<TaskList>, order: Map<Long, Int>): List<TaskList> = lists.sortedWith(
compareBy<TaskList>({ it.accountName.lowercase() }, { order[it.id] ?: 0 }, { it.name.lowercase() })
.thenBy { it.name },
)
}
@@ -67,6 +67,7 @@ class ModeRoutingTasksDataSource(
active().setCancelledInstance(taskId, occurrenceStart, cancelled)
override fun deleteTask(taskId: Long) = active().deleteTask(taskId)
override fun deleteOverride(taskId: Long, occurrenceStart: Instant) = active().deleteOverride(taskId, occurrenceStart)
override fun createLocalList(name: String, color: Int): Long = active().createLocalList(name, color)
override fun updateList(listId: Long, name: String, color: Int) = active().updateList(listId, name, color)
override fun deleteList(listId: Long) = active().deleteList(listId)
@@ -0,0 +1,23 @@
package de.jeanlucmakiola.agendula.data.tasks
/** SQLite's default bound-argument limit on older Android releases. */
internal const val MAX_SQL_ARGS = 999
/**
* `idColumn IN (…)` selections over [ids], each with its arguments and
* [filterArgs] after them, in chunks that keep every query within [limit]
* bound arguments.
*/
internal fun inSelections(
idColumn: String,
ids: Collection<Long>,
filter: String? = null,
filterArgs: List<String> = emptyList(),
limit: Int = MAX_SQL_ARGS,
): List<Pair<String, Array<String>>> = ids.distinct().chunked(limit - filterArgs.size).map { chunk ->
val selection = buildString {
append("$idColumn IN (${chunk.joinToString(",") { "?" }})")
if (filter != null) append(" AND ($filter)")
}
selection to (chunk.map(Long::toString) + filterArgs).toTypedArray()
}
@@ -0,0 +1,266 @@
package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.isClosed
import de.jeanlucmakiola.agendula.domain.recurrence.ExpansionWindow
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
import de.jeanlucmakiola.agendula.domain.recurrence.dateZone
import de.jeanlucmakiola.agendula.domain.statusFromInt
import java.time.Duration
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.temporal.ChronoUnit
import kotlin.time.Duration.Companion.days
import kotlin.time.Instant
private const val MILLIS_PER_SECOND = 1000L
private const val MILLIS_PER_DAY = 24L * 60 * 60 * 1000
/** A sub-task's progress, which a routine's next occurrence starts without. */
internal data class SubtaskState(val status: TaskStatus, val percent: Int?, val completedAt: Long?) {
/** Open with nothing done yet: what a reset leaves. */
val isFresh: Boolean get() = status == TaskStatus.NEEDS_ACTION && (percent ?: 0) == 0 && completedAt == null
companion object {
val FRESH = SubtaskState(TaskStatus.NEEDS_ACTION, null, null)
}
}
/** A sub-task's own start and due, as epoch millis. */
internal data class SubtaskDates(val start: Long?, val due: Long?) {
val isEmpty: Boolean get() = start == null && due == null
/** The same dates as a store keeps them: to the second, or for an all-day task to the day. */
fun sameAs(other: SubtaskDates, isAllDay: Boolean): Boolean {
fun unit(at: Long?) = at?.let { Math.floorDiv(it, if (isAllDay) MILLIS_PER_DAY else MILLIS_PER_SECOND) }
return unit(start) == unit(other.start) && unit(due) == unit(other.due)
}
companion object {
val NONE = SubtaskDates(null, null)
}
}
/**
* A sub-task as [SubtaskReset] sees it; [recurring] also covers a copy a tick of
* a recurring one left. Its dates are read in [dateZone] of [isAllDay] and [timeZone].
*/
internal class SubtaskNode(
val id: Long,
val recurring: Boolean,
val state: SubtaskState,
val dates: SubtaskDates = SubtaskDates.NONE,
val isAllDay: Boolean = false,
val timeZone: String? = null,
)
/**
* A sub-task [SubtaskReset] reset: its state and dates before, and the dates the
* reset gave it. A record from before dates were kept has neither, and its dates
* are neither compared nor restored.
*/
internal data class ResetSubtask(
val id: Long,
val prior: SubtaskState,
val priorDates: SubtaskDates? = SubtaskDates.NONE,
val dates: SubtaskDates? = priorDates,
)
/** A reset sub-task as it is now, for [SubtaskReset.toRestore]. */
internal class SubtaskNow(val state: SubtaskState, val dates: SubtaskDates, val isAllDay: Boolean)
/** What moving a series back does with its reset sub-tasks: who gets their state back, and who changed since. */
internal class SubtaskRestore(val restore: List<ResetSubtask>, val changed: List<Long>)
/**
* How far a series moved in [seriesZone]: wall-clock time for a timed sub-task,
* so it keeps its place relative to the routine across a DST change, and the
* change of calendar date for an all-day one. [floating] reads a sub-task with
* no zone of its own.
*/
internal class SeriesShift(from: Long, to: Long, seriesZone: ZoneId, private val floating: ZoneId = ZoneId.systemDefault()) {
private val delta = Duration.between(local(from, seriesZone), local(to, seriesZone))
private val days = ChronoUnit.DAYS.between(local(from, seriesZone).toLocalDate(), local(to, seriesZone).toLocalDate())
/** [at] moved along. */
fun apply(at: Long, isAllDay: Boolean, timeZone: String? = null): Long {
val zone = dateZone(isAllDay, timeZone, floating)
val moved = if (isAllDay) local(at, zone).plusDays(days) else local(at, zone).plus(delta)
return moved.atZone(zone).toInstant().toEpochMilli()
}
private fun local(at: Long, zone: ZoneId) = LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(at), zone)
}
/**
* What a routine's sub-tasks do when its series moves on to the next occurrence
* (#160): the ones completed or in progress start over, as in tasks.org, dated
* ones move along with the routine, and moving the series back gives them their
* state and dates again.
*/
internal object SubtaskReset {
/** Only bounds the search for a next occurrence. */
private val LOOKAHEAD = (366 * 1000).days
/**
* The `X-` property Agendula puts on the copy a provider tick of a recurring
* sub-task left, valued with the UID of the parent it sits under, so any
* device knows it for one while it stays there.
*/
const val COPY_MARKER = "X-AGENDULA-TICK-COPY"
/** The provider `properties` mimetype DAVx5 keeps unknown properties under, and round-trips. */
const val UNKNOWN_PROPERTY_MIMETYPE = "vnd.android.cursor.item/vnd.ical4android.unknown-property"
private val MARKER_JSON = Regex("""^\s*\[\s*"$COPY_MARKER"\s*,\s*"((?:[^"\\]|\\.)*)"""", RegexOption.IGNORE_CASE)
/** The marker for a copy under the parent [parentUid], as DAVx5 stores an unknown property: a JSON array of name and value. */
fun copyMarkerJson(parentUid: String): String =
"[\"$COPY_MARKER\",\"${parentUid.replace("\\", "\\\\").replace("\"", "\\\"")}\"]"
/** The parent UID an unknown-property row's JSON marks its task a copy under, or null when it is no marker. */
fun markerParentFromJson(json: String?): String? =
json?.let(MARKER_JSON::find)?.groupValues?.get(1)?.replace(Regex("""\\(.)"""), "$1")
/** The parent UID a stored residue's marker names, as a copy synced in from a provider install carries it. */
fun markerParentFromResidue(residue: String?): String? = residue?.lines()?.firstNotNullOfOrNull {
val line = it.trim()
val next = line.getOrNull(COPY_MARKER.length)
if (!line.startsWith(COPY_MARKER, ignoreCase = true) || (next != ':' && next != ';')) return@firstNotNullOfOrNull null
line.substringAfter(':').replace(Regex("""\\([\\;,])"""), "$1")
}
/** Whether a marker naming [markerParent] makes a task under [parentUid] a copy: only under the parent it came from. */
fun isCopyOf(markerParent: String?, parentUid: String?): Boolean = markerParent != null && markerParent == parentUid
/**
* The sub-tasks under [parentId], all the way down, to reset: started ones,
* and with a [shift], dated ones too. A recurring one has a series of its
* own and is left alone with everything under it; a cancelled one stays as
* it is.
*/
fun toReset(parentId: Long, shift: SeriesShift? = null, children: (Long) -> List<SubtaskNode>): List<ResetSubtask> {
val seen = mutableSetOf(parentId)
val reset = mutableListOf<ResetSubtask>()
fun walk(id: Long) {
children(id).filter { !it.recurring && seen.add(it.id) }.forEach {
val moves = shift != null && !it.dates.isEmpty
if (it.state.status != TaskStatus.CANCELLED && (!it.state.isFresh || moves)) {
val dates = if (shift == null) it.dates else SubtaskDates(
it.dates.start?.let { at -> shift.apply(at, it.isAllDay, it.timeZone) },
it.dates.due?.let { at -> shift.apply(at, it.isAllDay, it.timeZone) },
)
reset += ResetSubtask(it.id, it.state, it.dates, dates)
}
walk(it.id)
}
}
walk(parentId)
return reset
}
/**
* A provider row as a node. Nothing the provider sets on a detached copy marks
* it as one, so a copy is known by the [copies] ticks on this install left, or
* by a [markerParent] naming the [parentUid] it sits under: it belongs to a
* recurring sub-task and counts as recurring.
*/
fun providerNode(
id: Long,
rrule: String?,
rdate: String?,
copies: Set<Long>,
markerParent: String? = null,
parentUid: String? = null,
state: SubtaskState,
dates: SubtaskDates = SubtaskDates.NONE,
isAllDay: Boolean = false,
timeZone: String? = null,
) = SubtaskNode(
id,
!rrule.isNullOrBlank() || !rdate.isNullOrBlank() || id in copies || isCopyOf(markerParent, parentUid),
state, dates, isAllDay, timeZone,
)
/**
* The reset sub-tasks still as the reset left them, so moving back may
* restore them, and the ones changed since, which keep the user's state.
* A deleted one is neither.
*/
fun toRestore(reset: List<ResetSubtask>, current: (Long) -> SubtaskNow?): SubtaskRestore {
val restore = mutableListOf<ResetSubtask>()
val changed = mutableListOf<Long>()
reset.forEach { entry ->
val now = current(entry.id) ?: return@forEach
val unchanged = now.state.isFresh && (entry.dates == null || now.dates.sameAs(entry.dates, now.isAllDay))
if (unchanged) restore += entry else changed += entry.id
}
return SubtaskRestore(restore, changed)
}
fun encode(reset: List<ResetSubtask>): String = reset.joinToString(";") {
listOf(
it.id, it.prior.status.name, it.prior.percent, it.prior.completedAt,
it.priorDates?.start, it.priorDates?.due, it.dates?.start, it.dates?.due,
).joinToString(",") { value -> value?.toString().orEmpty() }
}
fun decode(raw: String?): List<ResetSubtask> = raw.orEmpty().split(';').mapNotNull { entry ->
val parts = entry.split(',')
if (parts.size != 4 && parts.size != 8) return@mapNotNull null
val id = parts[0].toLongOrNull() ?: return@mapNotNull null
val status = TaskStatus.entries.firstOrNull { it.name == parts[1] } ?: return@mapNotNull null
val state = SubtaskState(status, parts[2].toIntOrNull(), parts[3].toLongOrNull())
if (parts.size == 4) return@mapNotNull ResetSubtask(id, state, priorDates = null, dates = null)
ResetSubtask(
id, state,
SubtaskDates(parts[4].toLongOrNull(), parts[5].toLongOrNull()),
SubtaskDates(parts[6].toLongOrNull(), parts[7].toLongOrNull()),
)
}
/** A provider series' rule set from its columns. */
fun specOf(state: Map<String, Any?>): RecurrenceSpec? = TaskMapper.seriesSpec(MapColumnReader(state))
/** How the series moved from [before] to [after], read in the zone its rule is expanded in. */
fun shiftOf(before: Map<String, Any?>, after: Map<String, Any?>, floating: ZoneId = ZoneId.systemDefault()): SeriesShift? {
val from = anchor(before) ?: return null
val to = anchor(after) ?: return null
val reader = MapColumnReader(after)
return SeriesShift(from, to, dateZone(reader.getBoolean(Tasks.IS_ALLDAY), reader.getString(Tasks.TZ), floating), floating)
}
/**
* Whether a provider tick moved the series on, judged by its columns
* [before] and [after]: its start (or due) moved later and it is still open,
* with [next], its rule set as it now stands, generating an occurrence from
* there. An `EXDATE` or a status alone is no move; a series whose rule ran
* out is finished.
*/
fun movedOn(before: Map<String, Any?>?, after: Map<String, Any?>?, next: RecurrenceSpec? = after?.let(::specOf)): Boolean {
val was = anchor(before ?: return false) ?: return false
val now = anchor(after ?: return false) ?: return false
if (now <= was) return false
if (statusFromInt((after[Tasks.STATUS] as? Number)?.toInt()).isClosed) return false
val spec = next ?: return false
if (spec.rrule == null && spec.rdate == null) return false
val parts = spec.rrule?.split(';')?.map { it.trim() }?.filter { it.isNotEmpty() }
val count = parts?.firstOrNull { it.startsWith("COUNT=", ignoreCase = true) }?.substringAfter('=')?.toIntOrNull()
if (count != null && count <= 0) return false
// Expanded from the old start, as an expansion always yields its own start
// even past UNTIL. COUNT is left out: it was spent before the new start.
val probe = spec.copy(
rrule = parts?.filterNot { it.startsWith("COUNT=", ignoreCase = true) }?.joinToString(";"),
anchor = Instant.fromEpochMilliseconds(was),
)
val from = Instant.fromEpochMilliseconds(now)
return RecurrenceExpander.expand(probe, ExpansionWindow(from = from, until = from + LOOKAHEAD, maxOccurrences = 1)).isNotEmpty()
}
/** The anchor a series' columns put it at: its start, or else its due date. */
fun anchor(state: Map<String, Any?>): Long? =
(state[Tasks.DTSTART] as? Number)?.toLong() ?: (state[Tasks.DUE] as? Number)?.toLong()
}
@@ -8,25 +8,29 @@ import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.export.ExportTask
import de.jeanlucmakiola.agendula.domain.priorityFromICal
import de.jeanlucmakiola.agendula.domain.statusFromInt
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
import kotlin.time.Instant
/** Maps a provider row (via [ColumnReader]) to a domain model. Pure + testable. */
object TaskMapper {
fun task(r: ColumnReader): Task {
/**
* Maps an `instances` row, without its series' rule: the dmfs instances view
* (DB 22 and 23) masks `rrule`/`rdate` with `NULL`, so the row alone cannot
* tell a series from a one-off on tasks.org's bundled provider (DB 22), which
* also lacks `is_recurring`. [withRule] completes it with the rule read from
* the master's `tasks` row. An override names its master in
* `original_instance_id`.
*/
fun instance(r: ColumnReader): InstanceRow {
fun instant(name: String): Instant? =
r.getLong(name)?.let { Instant.fromEpochMilliseconds(it) }
val rowId = r.getLong(Tasks.ID) ?: 0L
// Derived from the rule columns rather than the `is_recurring` column
// alone: that column only exists from OpenTasks 1.4.0 (DB 23) and is
// absent on tasks.org's bundled provider (DB 22), where reading it
// would silently report every recurring task as one-off — and route
// its edits onto the series anchor.
val recurring = r.getString(Tasks.RRULE) != null ||
r.getString(Tasks.RDATE) != null ||
r.getBoolean(Instances.IS_RECURRING)
return Task(
val masterId = r.getLong(Tasks.ORIGINAL_INSTANCE_ID)
val recurring = masterId != null || r.getBoolean(Instances.IS_RECURRING)
val anchor = occurrenceAnchor(r)
val task = Task(
taskId = r.getLong(Instances.TASK_ID) ?: rowId,
listId = r.getLong(Tasks.LIST_ID) ?: 0L,
title = r.getString(Tasks.TITLE).orEmpty(),
@@ -47,12 +51,44 @@ object TaskMapper {
accountName = r.getString(Tasks.ACCOUNT_NAME),
parentId = r.getLong(Tasks.PARENT_ID),
isRecurring = recurring,
occurrenceStart = if (recurring) occurrenceAnchor(r) else null,
occurrenceStart = if (recurring) anchor else null,
distanceFromCurrent = r.getInt(Instances.DISTANCE_FROM_CURRENT),
created = instant(Tasks.CREATED),
lastModified = instant(Tasks.LAST_MODIFIED),
seriesId = if (recurring) r.getLong(Instances.TASK_ID) ?: rowId else null,
recurrenceRule = r.getString(Tasks.RRULE),
seriesId = if (recurring) masterId ?: r.getLong(Instances.TASK_ID) ?: rowId else null,
)
return InstanceRow(task, ruleOwner = masterId ?: task.taskId, anchor = anchor)
}
/** [row]'s task as an occurrence of the series [rule] belongs to, or as it is without one. */
fun withRule(row: InstanceRow, rule: SeriesRule?): Task {
if (rule == null) return row.task
return row.task.copy(
isRecurring = true,
occurrenceStart = row.task.occurrenceStart ?: row.anchor,
seriesId = row.task.seriesId ?: row.ruleOwner,
recurrenceRule = rule.rrule,
)
}
/** The rule columns of a `tasks` row, or null when it does not recur. */
fun seriesRule(r: ColumnReader): SeriesRule? {
val rrule = r.getString(Tasks.RRULE)?.ifBlank { null }
val rdate = r.getString(Tasks.RDATE)?.ifBlank { null }
return if (rrule == null && rdate == null) null else SeriesRule(rrule, rdate)
}
/** The rule set of a `tasks` row, anchored on its start or else its due date; null without either date. */
fun seriesSpec(r: ColumnReader): RecurrenceSpec? {
val rule = seriesRule(r)
val anchor = r.getLong(Tasks.DTSTART) ?: r.getLong(Tasks.DUE) ?: return null
return RecurrenceSpec(
rrule = rule?.rrule,
rdate = rule?.rdate,
exdate = r.getString(Tasks.EXDATE),
anchor = Instant.fromEpochMilliseconds(anchor),
isAllDay = r.getBoolean(Tasks.IS_ALLDAY),
timeZone = r.getString(Tasks.TZ),
)
}
@@ -119,3 +155,12 @@ object TaskMapper {
owner = r.getString(Lists.OWNER),
)
}
/** A series' recurrence as stored on its master row. */
data class SeriesRule(val rrule: String?, val rdate: String?)
/**
* An instance row's task before its series' rule is applied. [ruleOwner] is the
* task whose `tasks` row carries that rule: the master, for an override.
*/
data class InstanceRow(val task: Task, val ruleOwner: Long, val anchor: Instant?)
@@ -17,6 +17,20 @@ private fun Instant.forAllDay(allDay: Boolean): Instant =
Math.floorDiv(toEpochMilliseconds(), MILLIS_PER_DAY) * MILLIS_PER_DAY,
)
/**
* Whole seconds, as iCalendar stores times. The provider expands a series from
* second-precision instants, so a DTSTART carrying milliseconds never matches its
* own first occurrence: completing it detaches a copy but leaves the series where
* it was, and the same occurrence comes straight back.
*/
fun providerMillis(epochMillis: Long): Long = Math.floorDiv(epochMillis, 1000L) * 1000L
/** [at] cut to its whole second, as [providerMillis] does. */
fun wholeSecond(at: Instant): Instant = Instant.fromEpochMilliseconds(providerMillis(at.toEpochMilliseconds()))
/** True when [a] and [b] fall in the same whole second. */
fun sameSecond(a: Instant, b: Instant): Boolean = wholeSecond(a) == wholeSecond(b)
/**
* Turns a [TaskForm] / mutation into a name→value map. Pure (no ContentValues),
* so it unit-tests on the JVM; [AndroidTasksDataSource] converts the map to
@@ -57,8 +71,8 @@ object TaskWriteMapper {
// through DateTime.toAllDay(), which drops the time-of-day and resolves the
// remaining date against UTC — so a local-midnight instant lands on the
// previous day for anyone west of UTC. Pin all-day values to UTC midnight.
put(Tasks.DTSTART, form.start?.forAllDay(form.isAllDay)?.toEpochMilliseconds())
put(Tasks.DUE, form.due?.forAllDay(form.isAllDay)?.toEpochMilliseconds())
put(Tasks.DTSTART, form.start?.forAllDay(form.isAllDay)?.toEpochMilliseconds()?.let(::providerMillis))
put(Tasks.DUE, form.due?.forAllDay(form.isAllDay)?.toEpochMilliseconds()?.let(::providerMillis))
// DUE and DURATION are mutually exclusive. The provider's Validating
// processor evaluates the *merged* row (supplied values over the stored
// ones), so writing DUE onto a task that already carries a DURATION throws
@@ -124,13 +124,17 @@ interface TasksDataSource {
*
* Implementations fall back to [setCompleted] when the row turns out not to
* be a series master — an override, or a plain task the caller happened to
* hand an anchor for — so the routing above cannot get this wrong.
* hand an anchor for. A series whose occurrence cannot be found fails with
* [TaskWriteFailedException] instead: the plain write would close all of it.
*/
fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean)
/** Cancel (or restore) one occurrence of a series; the counterpart of [setCompletedInstance]. */
fun setCancelledInstance(taskId: Long, occurrenceStart: Instant, cancelled: Boolean)
fun deleteTask(taskId: Long)
/** Delete the override [taskId] standing for one occurrence, excluding that occurrence from its series. */
fun deleteOverride(taskId: Long, occurrenceStart: Instant) = deleteTask(taskId)
fun createLocalList(name: String, color: Int): Long
/** Rename and recolour [listId]. */
@@ -68,9 +68,11 @@ class TasksRepositoryImpl @Inject constructor(
override fun taskDetail(taskId: Long, occurrenceStart: Instant?): Flow<TaskDetail?> = observing {
dataSource.task(taskId, occurrenceStart)?.let { task ->
// A series' subtasks hang off its master, also when this occurrence is an override.
val owner = task.seriesId ?: resolvedRow(task, taskId)
TaskDetail(
task = task,
subtasks = SeriesCollapse.visible(dataSource.subtasks(taskId)).filter { it.taskId != taskId },
subtasks = SeriesCollapse.visible(dataSource.subtasks(owner)).filter { it.taskId != owner },
parent = task.parentId?.takeIf { it > 0 }?.let { dataSource.task(it) },
)
}
@@ -172,7 +174,9 @@ class TasksRepositoryImpl @Inject constructor(
) = withContext(io) {
// Re-read just before writing: it settles the conflict check *and* tells
// us which URI to write through.
// An occurrence that is gone is not written through to its series.
val current = dataSource.task(taskId, occurrenceStart)
?: if (occurrenceStart != null) throw TaskWriteFailedException("update task $taskId@$occurrenceStart: gone") else null
// Conflict-safe overwrite: bail if the provider's last_modified moved
// since the form captured it (external sync / another app). A null
// baseline means "force / overwrite anyway".
@@ -201,7 +205,7 @@ class TasksRepositoryImpl @Inject constructor(
// 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) dataSource.updateTask(taskId, form, reminders)
if (instance == null) dataSource.updateTask(resolvedRow(current, taskId), form, reminders)
else dataSource.updateInstance(taskId, instance, form, reminders)
}
@@ -233,14 +237,16 @@ class TasksRepositoryImpl @Inject constructor(
override suspend fun deleteTask(taskId: Long, occurrenceStart: Instant?, scope: RecurringScope) =
withContext(io) {
val current = dataSource.task(taskId, occurrenceStart)
// An occurrence that is gone leaves nothing to delete; its series stays.
if (current == null && occurrenceStart != null) return@withContext
val seriesId = current?.seriesId
val occurrence = current?.occurrenceStart
if (seriesId == null || occurrence == null) return@withContext dataSource.deleteWithSubtasks(taskId)
if (seriesId == null || occurrence == null) return@withContext dataSource.deleteWithSubtasks(resolvedRow(current, taskId))
when (scope) {
RecurringScope.ThisOccurrence ->
// An override row deletes itself into an EXDATE; a generated
// occurrence has no row to delete.
if (current.taskId != seriesId) dataSource.deleteTask(current.taskId)
if (current.taskId != seriesId) dataSource.deleteOverride(current.taskId, occurrence)
else dataSource.deleteOccurrence(seriesId, occurrence)
RecurringScope.ThisAndFollowing -> dataSource.deleteFollowing(seriesId, occurrence)
RecurringScope.AllOccurrences -> dataSource.deleteWithSubtasks(seriesId)
@@ -291,6 +297,13 @@ class TasksRepositoryImpl @Inject constructor(
ticks.close()
}
}.flowOn(io)
/**
* The row a read for [taskId] resolved to — a past occurrence the series moved
* past is a task of its own — which is the one a write has to address.
*/
private fun resolvedRow(resolved: Task?, taskId: Long): Long = resolved?.taskId ?: taskId
}
/** Subtask levels a duplicate follows; deeper than any real list, short of a cycle. */
@@ -0,0 +1,42 @@
package de.jeanlucmakiola.agendula.data.tasks
import android.content.SharedPreferences
/**
* The sub-tasks a provider tick reset, as [SubtaskReset.encode] writes them,
* kept for [TickedCopies.HORIZON_MS] under the series and the anchor the tick
* moved it to: whether or not the provider left a copy that [TickedCopies]
* could record, moving the series back from there finds them.
*/
internal class TickResets(private val prefs: () -> SharedPreferences) {
fun put(authority: String, seriesId: Long, anchor: Long, reset: String, at: Long) {
val stale = expire(prefs().all, at)
prefs().edit().apply {
stale.forEach(::remove)
putString(key(authority, seriesId, anchor), encode(at, reset))
}.apply()
}
fun get(authority: String, seriesId: Long, anchor: Long): String? =
prefs().getString(key(authority, seriesId, anchor), null)?.let(::decode)
fun remove(authority: String, seriesId: Long, anchor: Long) {
prefs().edit().remove(key(authority, seriesId, anchor)).apply()
}
companion object {
fun key(authority: String, seriesId: Long, anchor: Long) = "$authority|$seriesId|$anchor"
fun encode(at: Long, reset: String) = "$at|$reset"
/** The reset an entry holds, or null when it is not one. */
fun decode(raw: String): String? = raw.substringBefore('|', "").toLongOrNull()?.let { raw.substringAfter('|') }
/** The keys of [entries] past the horizon at [now], unreadable ones included. */
fun expire(entries: Map<String, *>, now: Long): Set<String> = entries.filter { (_, value) ->
val at = (value as? String)?.substringBefore('|', "")?.toLongOrNull() ?: 0L
now - at > TickedCopies.HORIZON_MS
}.keys
}
}
@@ -0,0 +1,119 @@
package de.jeanlucmakiola.agendula.data.tasks
import android.content.SharedPreferences
import org.json.JSONObject
import kotlin.time.Instant
/**
* What ticking a provider occurrence left: the copy the provider detached, and
* the series' columns before and after the tick, so an undo can move it back.
* The sub-tasks it reset are kept apart, in [TickResets].
*/
internal class Tick(
val copyId: Long,
val seriesId: Long?,
val before: Map<String, Any?>?,
val after: Map<String, Any?>?,
val at: Long,
)
/**
* The provider keeps no record of its ticks, so they are kept here for
* [HORIZON_MS]. The copies' ids are kept for good in [known]: a detached copy
* carries nothing that marks it as one.
*/
internal class TickedCopies(private val prefs: () -> SharedPreferences, private val known: () -> SharedPreferences) {
fun key(authority: String, taskId: Long, occurrenceStart: Instant) =
"$authority|$taskId|${wholeSecond(occurrenceStart).epochSeconds}"
fun get(key: String): Tick? = prefs().getString(key, null)?.let(::decode)
/** The tick that left [copyId], with its key. */
fun forCopy(authority: String, copyId: Long): Pair<String, Tick>? =
prefs().all.entries.firstNotNullOfOrNull { (key, value) ->
if (!key.startsWith("$authority|")) return@firstNotNullOfOrNull null
(value as? String)?.let(::decode)?.takeIf { it.copyId == copyId }?.let { key to it }
}
/** Every copy a tick in Agendula left in [authority]. */
fun copies(authority: String): Set<Long> {
val kept = knownIds(authority).mapNotNullTo(HashSet()) { it.toLongOrNull() }
prefs().all.forEach { (key, value) ->
if (key.startsWith("$authority|")) (value as? String)?.let(::decode)?.let { kept += it.copyId }
}
return kept
}
/** Records [tick] under [key]; ticks past [HORIZON_MS] go, their copies stay known. */
fun put(key: String, tick: Tick) {
val (stale, keep) = expire(prefs().all.mapValues { (_, value) -> (value as? String)?.let(::decode) }, tick.at)
val known = keep + (key.substringBefore('|') to (keep[key.substringBefore('|')].orEmpty() + tick.copyId))
known().edit().apply {
known.forEach { (authority, ids) -> putStringSet(authority, knownIds(authority) + ids.map(Long::toString)) }
}.apply()
prefs().edit().apply {
stale.forEach(::remove)
putString(key, encode(tick))
}.apply()
}
/** Drops the tick under [key], and its copy from the known ones: moving back deleted it, and its id may be reused. */
fun remove(key: String) {
val copy = get(key)?.copyId
prefs().edit().remove(key).apply()
if (copy != null) {
val authority = key.substringBefore('|')
known().edit().putStringSet(authority, knownIds(authority) - copy.toString()).apply()
}
}
private fun knownIds(authority: String): Set<String> = known().getStringSet(authority, emptySet()).orEmpty().toSet()
private fun encode(tick: Tick): String = JSONObject().apply {
put("copy", tick.copyId)
put("at", tick.at)
tick.seriesId?.let { put("series", it) }
tick.before?.let { put("before", columns(it)) }
tick.after?.let { put("after", columns(it)) }
}.toString()
private fun columns(values: Map<String, Any?>) = JSONObject().apply {
values.forEach { (column, value) -> put(column, value ?: JSONObject.NULL) }
}
private fun decode(raw: String): Tick? = runCatching {
val json = JSONObject(raw)
Tick(
copyId = json.getLong("copy"),
seriesId = if (json.has("series")) json.getLong("series") else null,
before = json.optJSONObject("before")?.let(::values),
after = json.optJSONObject("after")?.let(::values),
at = json.getLong("at"),
)
}.getOrNull()
private fun values(json: JSONObject): Map<String, Any?> = json.keys().asSequence().associateWith { column ->
when (val value = json.get(column)) {
JSONObject.NULL -> null
is Number -> value.toLong()
else -> value.toString()
}
}
companion object {
/** As long as the own store keeps a tick undoable. */
const val HORIZON_MS = 30L * 24 * 60 * 60 * 1000
/**
* The keys of [ticks] past the horizon at [now], unreadable ones included,
* and per authority the copies they leave, which stay known after the tick goes.
*/
fun expire(ticks: Map<String, Tick?>, now: Long): Pair<Set<String>, Map<String, Set<Long>>> {
val stale = ticks.filter { (_, tick) -> now - (tick?.at ?: 0L) > HORIZON_MS }
val keep = stale.entries.mapNotNull { (key, tick) -> tick?.let { key.substringBefore('|') to it.copyId } }
.groupBy({ it.first }, { it.second }).mapValues { it.value.toSet() }
return stale.keys to keep
}
}
}
@@ -62,39 +62,33 @@ class ProviderWriter internal constructor(
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")
}
fun insert(table: ProviderTable, values: ProviderValues): Long = when (table) {
ProviderTable.TASKS -> write { insertTask(values) }
ProviderTable.PROPERTIES -> write { insertProperty(values) }
ProviderTable.LISTS, ProviderTable.INSTANCES -> readOnly(table)
}
/** 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")
fun update(table: ProviderTable, values: ProviderValues, selection: String?, args: Array<out String>?): Int = when (table) {
ProviderTable.TASKS -> write {
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 -> write {
val writable = ProviderTaskWriter.writable(table, values, WRITABLE_TASK_COLUMNS)
instances(selection, args).count { updateInstance(it, writable) }
}
ProviderTable.PROPERTIES -> write { ids(table, selection, args).count { updateProperty(it, values) } }
ProviderTable.LISTS -> readOnly(table)
}
/** 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")
}
fun delete(table: ProviderTable, selection: String?, args: Array<out String>?): Int = when (table) {
ProviderTable.TASKS -> write { ids(table, selection, args).count { deleteTask(it) } }
ProviderTable.INSTANCES -> write { instances(selection, args).count { deleteInstance(it) } }
ProviderTable.PROPERTIES -> write { ids(table, selection, args).count { deleteProperty(it) } }
ProviderTable.LISTS -> readOnly(table)
}
/** Runs a client's batch in one transaction, re-arming reminders once at the end. */
@@ -113,6 +107,9 @@ class ProviderWriter internal constructor(
}
}
/** Refused as read-only whichever store is active, before the store is even looked at. */
private fun readOnly(table: ProviderTable): Nothing = throw UnsupportedOperationException("${table.path} is read-only")
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"
@@ -192,7 +189,8 @@ class ProviderWriter internal constructor(
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)
val occurrence = current.recurrenceId
if (current.masterId != null && occurrence != null) store.deleteOverride(taskId, occurrence) else store.deleteWithSubtasks(taskId)
return true
}
@@ -0,0 +1,133 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.ColumnInfo
import androidx.room.Dao
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
import de.jeanlucmakiola.agendula.domain.TaskStatus
import kotlin.time.Instant
/**
* How a series was moved past one of its occurrences (#137). It maps the
* occurrence to its completed copy for as long as the copy lives, and while
* [undoable] it also holds what reopening the copy needs to move the series
* back: the change, not the old series, so edits made since are kept. Local
* only: never part of a `VTODO`.
*/
@Entity(
tableName = "advanced_occurrences",
foreignKeys = [
ForeignKey(entity = TaskEntity::class, parentColumns = ["id"], childColumns = ["copy_id"], onDelete = ForeignKey.CASCADE),
ForeignKey(entity = TaskEntity::class, parentColumns = ["id"], childColumns = ["master_id"], onDelete = ForeignKey.CASCADE),
],
indices = [Index(value = ["master_id", "anchor"])],
)
data class AdvancedOccurrenceEntity(
/** The completed copy of the occurrence. */
@PrimaryKey @ColumnInfo(name = "copy_id") val copyId: Long,
@ColumnInfo(name = "master_id") val masterId: Long,
@ColumnInfo(name = "anchor") val anchor: Instant,
/** The anchor the series moved to; it is only moved back while it still has it. */
@ColumnInfo(name = "advanced_to") val advancedTo: Instant,
@ColumnInfo(name = "prior_dtstart") val priorDtstart: Instant?,
@ColumnInfo(name = "prior_due") val priorDue: Instant?,
@ColumnInfo(name = "prior_status") val priorStatus: TaskStatus,
@ColumnInfo(name = "prior_percent_complete") val priorPercentComplete: Int?,
@ColumnInfo(name = "prior_completed_at") val priorCompletedAt: Instant?,
/** Occurrences `COUNT` lost to the move. */
@ColumnInfo(name = "spent") val spent: Int,
/** The `RRULE` the move left; once it differs, the series was reshaped and is not moved back. */
@ColumnInfo(name = "rule_after") val ruleAfter: String?,
/** The `EXDATE` occurrences the move dropped, as epoch millis, comma-separated. */
@ColumnInfo(name = "dropped_exdates", defaultValue = "") val droppedExdates: String = "",
/** The copy was an override before, and goes back to being one. */
@ColumnInfo(name = "from_override") val fromOverride: Boolean,
/** Cleared once the copy is edited, moved or reopened on its own: it is then a task of its own. */
@ColumnInfo(name = "undoable", defaultValue = "1") val undoable: Boolean = true,
/** Set once the moved series reached the server, which then holds the move itself. */
@ColumnInfo(name = "settled", defaultValue = "0") val settled: Boolean = false,
/** The series' sub-tasks the move reset, with their state before, as [de.jeanlucmakiola.agendula.data.tasks.SubtaskReset.encode] writes them. */
@ColumnInfo(name = "reset_subtasks", defaultValue = "") val resetSubtasks: String = "",
) {
val dropped: List<Instant>
get() = droppedExdates.split(',').mapNotNull { it.toLongOrNull()?.let(Instant::fromEpochMilliseconds) }
companion object {
fun encode(instants: List<Instant>) = instants.joinToString(",") { it.toEpochMilliseconds().toString() }
}
}
/** A closed override detached along with an advance; [taskId] is null once that row is gone. */
@Entity(
tableName = "advanced_detached",
foreignKeys = [
ForeignKey(
entity = AdvancedOccurrenceEntity::class,
parentColumns = ["copy_id"],
childColumns = ["copy_id"],
onDelete = ForeignKey.CASCADE,
),
ForeignKey(entity = TaskEntity::class, parentColumns = ["id"], childColumns = ["task_id"], onDelete = ForeignKey.SET_NULL),
],
indices = [Index(value = ["copy_id"]), Index(value = ["task_id"])],
)
data class AdvancedDetachedEntity(
@PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0,
@ColumnInfo(name = "copy_id") val copyId: Long,
@ColumnInfo(name = "anchor") val anchor: Instant,
@ColumnInfo(name = "task_id") val taskId: Long?,
)
@Dao
interface AdvancedOccurrenceDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(row: AdvancedOccurrenceEntity)
@Insert
fun insertDetached(rows: List<AdvancedDetachedEntity>)
@Query("SELECT * FROM advanced_occurrences WHERE copy_id = :copyId")
fun forCopy(copyId: Long): AdvancedOccurrenceEntity?
@Query("SELECT * FROM advanced_detached WHERE copy_id = :copyId")
fun detachedFor(copyId: Long): List<AdvancedDetachedEntity>
@Query("SELECT * FROM advanced_occurrences WHERE master_id = :masterId")
fun forMaster(masterId: Long): List<AdvancedOccurrenceEntity>
/** The record for [masterId]'s occurrence within the second starting at [secondStart]. */
@Query(
"SELECT * FROM advanced_occurrences WHERE master_id = :masterId " +
"AND anchor >= :secondStart AND anchor < :secondStart + 1000",
)
fun at(masterId: Long, secondStart: Instant): AdvancedOccurrenceEntity?
@Query("DELETE FROM advanced_occurrences WHERE copy_id = :copyId")
fun delete(copyId: Long)
@Query("UPDATE advanced_occurrences SET undoable = 0 WHERE copy_id = :copyId")
fun keepAsOwnTask(copyId: Long)
@Query("UPDATE advanced_occurrences SET settled = 1 WHERE master_id IN (:masterIds)")
fun settle(masterIds: List<Long>)
/** Settles [masterId]'s moves to before [until], the anchor the server now holds. */
@Query("UPDATE advanced_occurrences SET settled = 1 WHERE master_id = :masterId AND advanced_to < :until")
fun settleUpTo(masterId: Long, until: Instant)
/** Records of ticks made before [before]: too old to undo. Counted from the tick, not the occurrence, which may be long overdue. */
@Query(
"DELETE FROM advanced_occurrences WHERE master_id = :masterId " +
"AND copy_id IN (SELECT id FROM tasks WHERE created_at < :before)",
)
fun pruneTickedBefore(masterId: Long, before: Instant)
/** [taskId], once detached with a tick, has a life of its own and no longer returns to the series. */
@Query("UPDATE advanced_detached SET task_id = NULL WHERE task_id = :taskId")
fun unlinkDetached(taskId: Long)
}
@@ -4,6 +4,7 @@ import de.jeanlucmakiola.agendula.domain.LocalAccount
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.export.ExportTask
import de.jeanlucmakiola.agendula.domain.isClosed
import de.jeanlucmakiola.agendula.domain.priorityFromICal
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
import kotlin.time.Instant
@@ -100,6 +101,10 @@ object RoomTaskMapper {
val TaskEntity.isRecurring: Boolean
get() = !rrule.isNullOrBlank() || !rdate.isNullOrBlank()
/** Completed or cancelled. */
val TaskEntity.isClosed: Boolean
get() = status.isClosed
/**
* The series anchor: `DTSTART` when present, else `DUE`. A `VTODO` may carry only
* a due date, and RFC 5545 then anchors the recurrence on it — matching how the
@@ -1,19 +1,32 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import android.util.Log
import androidx.room.InvalidationTracker
import de.jeanlucmakiola.agendula.data.tasks.ResetSubtask
import de.jeanlucmakiola.agendula.data.tasks.SeriesShift
import de.jeanlucmakiola.agendula.data.tasks.SubtaskDates
import de.jeanlucmakiola.agendula.data.tasks.SubtaskNode
import de.jeanlucmakiola.agendula.data.tasks.SubtaskNow
import de.jeanlucmakiola.agendula.data.tasks.SubtaskReset
import de.jeanlucmakiola.agendula.data.tasks.SubtaskState
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
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.data.tasks.sameSecond
import de.jeanlucmakiola.agendula.data.tasks.wholeSecond
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.export.ExportTask
import de.jeanlucmakiola.agendula.domain.normalizeTags
import de.jeanlucmakiola.agendula.domain.recurrence.ExpansionWindow
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
import java.time.ZoneId
import de.jeanlucmakiola.agendula.domain.recurrence.dateZone
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@@ -28,6 +41,12 @@ private val WINDOW_FORWARD = 730.days
/** Residue lines that describe the old series and must not follow the new one. */
private val SERIES_LINES = listOf("EXDATE", "RDATE", "RRULE", "RECURRENCE-ID", "UID")
/** How long a record keeps mapping a ticked occurrence to its copy. */
private val RECORD_HORIZON = 30.days
/** Only bounds the search: the occurrence count is what stops expansion, so no real gap is cut off. */
private val SERIES_LOOKAHEAD = (366 * 1000).days
/** Enough to count every occurrence before a split point without bounding a real series. */
internal const val SPLIT_COUNT_CEILING = 100_000
@@ -81,6 +100,8 @@ private val TASK_TABLES = arrayOf("tasks", "task_lists", "task_alarms")
/** Distinct alarm texts whose `ACKNOWLEDGED` is remembered between scheduler passes. */
private const val ACK_CACHE_SIZE = 512
private const val TAG = "RoomTasksDataSource"
/**
* [TasksDataSource] over Agendula's own Room store.
*
@@ -103,6 +124,7 @@ class RoomTasksDataSource @Inject constructor(
private val tasks get() = database.tasks()
private val lists get() = database.taskLists()
private val alarms get() = database.alarms()
private val advanced get() = database.advancedOccurrences()
// --- reads ----------------------------------------------------------------
@@ -130,9 +152,13 @@ class RoomTasksDataSource @Inject constructor(
if (occurrenceStart == null) return task(taskId)
val row = tasks.task(taskId) ?: return null
if (row.task.recurrenceId != null || !row.task.isRecurring) return task(taskId)
return occurrencesOf(row, tasks.overrides(taskId), clock.now())
.firstOrNull { it.occurrenceStart == occurrenceStart }
?: task(taskId)
val occurrences = occurrencesOf(row, tasks.overrides(taskId), clock.now())
// Null rather than another occurrence: a write addressed to this one must not land on that.
return occurrences.firstOrNull { it.occurrenceStart == occurrenceStart }
?: occurrences.firstOrNull { it.occurrenceStart?.let { a -> sameSecond(a, occurrenceStart) } == true }
?: copyAt(row.task, occurrenceStart)?.let { copy ->
tasks.task(copy.id)?.let { RoomTaskMapper.task(it).copy(occurrenceStart = occurrenceStart) }
}
}
private fun overrideTask(row: TaskRow, anchor: Instant): Task =
@@ -230,10 +256,8 @@ class RoomTasksDataSource @Inject constructor(
* 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 writeReminders(taskId: Long, reminders: List<TaskReminder>?): Boolean =
reminders != null && alarms.syncForTask(taskId, reminders)
private fun storedReminders(taskId: Long): List<TaskReminder> =
alarms.forTask(taskId).map { it.asReminder() }.sortedWith(TaskReminderOrder)
@@ -241,7 +265,7 @@ class RoomTasksDataSource @Inject constructor(
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)
val remindersChanged = 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}")
move(current, TaskFormWriter.withRule(edited, form.rrule))
@@ -256,18 +280,23 @@ class RoomTasksDataSource @Inject constructor(
// in both (the task query skips non-null `master_id`, and the override
// query finds no master in B) while still uploading as part of A's
// resource.
tasks.update(
saveRow(
current,
if (current.masterId == null) {
TaskFormWriter.withRule(edited, form.rrule)
} else {
edited.copy(listId = current.listId, parentId = current.parentId)
},
changedBesides = remindersChanged,
)
writes.onWritten(setOf(current.listId))
}
/** See [TaskMove]: the rows move, and the old collection is left a tombstone. */
private fun move(current: TaskEntity, edited: TaskEntity) {
// A copy moved elsewhere is a task of its own now.
advanced.keepAsOwnTask(current.id)
advanced.unlinkDetached(current.id)
val plan = TaskMove.plan(
edited = edited,
previous = current,
@@ -323,11 +352,7 @@ class RoomTasksDataSource @Inject constructor(
return@tx
}
val now = clock.now()
// COUNT counts before EXDATE is applied (RFC 5545 §3.8.5.3).
val spent = RecurrenceExpander.expand(
spec.copy(exdate = null),
ExpansionWindow(from = anchor, until = occurrenceStart, maxOccurrences = SPLIT_COUNT_CEILING),
).size
val spent = spentBefore(spec, occurrenceStart)
val (start, due) = resolvedTimes(master, occurrenceStart)
tasks.update(TaskFormWriter.endedBefore(master, occurrenceStart, now, zone()))
@@ -350,9 +375,7 @@ class RoomTasksDataSource @Inject constructor(
percentComplete = null,
completedAt = null,
// Recurrence lines belong to the old series; everything else carries over.
unknownProperties = master.unknownProperties?.lines()
?.filterNot { line -> SERIES_LINES.any { line.startsWith(it, ignoreCase = true) } }
?.joinToString("\r\n")?.ifBlank { null },
unknownProperties = seriesFreeText(master.unknownProperties),
)
val applied = TaskFormWriter.apply(base, form.copy(percentComplete = null), now, zone())
val delta = applied.dtstart?.let { newStart -> start?.let { newStart - it } }
@@ -411,9 +434,12 @@ class RoomTasksDataSource @Inject constructor(
private inline fun tx(crossinline block: () -> Unit) = database.runInTransaction(Runnable { block() })
private fun seriesMaster(seriesId: Long): TaskEntity {
val row = tasks.entity(seriesId) ?: throw TaskWriteFailedException("series $seriesId")
return row.masterId?.let { tasks.entity(it) } ?: row
private fun seriesMaster(seriesId: Long): TaskEntity = rowAndMaster(seriesId).second
/** [taskId]'s row, and its series' master — the row itself unless it is an override. */
private fun rowAndMaster(taskId: Long): Pair<TaskEntity, TaskEntity> {
val row = tasks.entity(taskId) ?: throw TaskWriteFailedException("series $taskId")
return row to (row.masterId?.let { tasks.entity(it) } ?: row)
}
/** The occurrence's own times: its override's if it has one, else generated. */
@@ -460,62 +486,408 @@ class RoomTasksDataSource @Inject constructor(
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) = tx {
if (!alarms.syncForTask(taskId, reminders)) return@tx
advanced.keepAsOwnTask(taskId)
advanced.unlinkDetached(taskId)
// 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) {
/** Reopening the copy a ticked occurrence left behind moves its series back, as in [setCompletedInstance]. */
override fun setCancelled(taskId: Long, cancelled: Boolean) = tx {
val current = tasks.entity(taskId) ?: throw TaskWriteFailedException("cancel task $taskId")
tasks.update(TaskFormWriter.cancelled(current, cancelled, clock.now()))
saveRow(current, TaskFormWriter.cancelled(current, cancelled, clock.now()))
writes.onWritten(setOf(current.listId))
}
override fun setCompleted(taskId: Long, completed: Boolean) {
/** Reopening the copy a ticked occurrence left behind moves its series back, as in [setCompletedInstance]. */
override fun setCompleted(taskId: Long, completed: Boolean) = tx {
val current = tasks.entity(taskId) ?: throw TaskWriteFailedException("complete task $taskId")
tasks.update(TaskFormWriter.completed(current, completed, clock.now()))
saveRow(current, TaskFormWriter.completed(current, completed, clock.now()))
writes.onWritten(setOf(current.listId))
}
/**
* Ticking one occurrence forks a `RECURRENCE-ID` override carrying the
* completion — the same model [updateInstance] writes. Writing the status onto
* the master instead would close the series: the master is what
* [TaskDao.tasks] filters on, so every occurrence, past and future, would
* leave every list at once.
* Ticking a series' first open occurrence moves the series on to its next
* open one and keeps a completed copy, as tasks.org and Apple Reminders do — a
* completed `RECURRENCE-ID` override alone leaves the series showing as open
* in most other clients (#137). Closed overrides between become copies too.
* Reopening the copy while the series is still where that left it moves it
* back. Any other occurrence forks an override carrying the completion, the
* same model [updateInstance] writes. Writing the status onto the master
* instead would close the series.
*/
override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) =
writeInstance(taskId, occurrenceStart, "complete") { TaskFormWriter.completed(it, completed, clock.now()) }
override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) = tx {
closeOccurrence(taskId, occurrenceStart, completed) { TaskFormWriter.completed(it, completed, clock.now()) }
}
/** Same override model as [setCompletedInstance]. */
override fun setCancelledInstance(taskId: Long, occurrenceStart: Instant, cancelled: Boolean) =
writeInstance(taskId, occurrenceStart, "cancel") { TaskFormWriter.cancelled(it, cancelled, clock.now()) }
/** Same model as [setCompletedInstance]. */
override fun setCancelledInstance(taskId: Long, occurrenceStart: Instant, cancelled: Boolean) = tx {
closeOccurrence(taskId, occurrenceStart, cancelled) { TaskFormWriter.cancelled(it, cancelled, clock.now()) }
}
/** Applies [write] to the override for [occurrenceStart], forking one if there is none yet. */
private fun writeInstance(
taskId: Long,
occurrenceStart: Instant,
what: String,
write: (TaskEntity) -> TaskEntity,
) {
val master = tasks.entity(taskId) ?: throw TaskWriteFailedException("$what instance $taskId")
// Not a series master — an override, or a plain task the caller handed an
// anchor for. Either way this row *is* the occurrence.
private fun closeOccurrence(taskId: Long, occurrenceStart: Instant, closing: Boolean, close: (TaskEntity) -> TaskEntity) {
val (row, master) = rowAndMaster(taskId)
if (master.recurrenceSpec() == null) {
tasks.update(write(master))
writes.onWritten(setOf(master.listId))
// A plain task, or the copy a ticked occurrence left: this row is the occurrence.
saveRow(row, close(row))
writes.onWritten(setOf(row.listId))
return
}
val override = if (row.masterId != null) row else tasks.override(master.id, occurrenceStart)
val anchor = override?.recurrenceId ?: occurrenceStart
val seriesStart = master.recurrenceAnchor ?: return
if (anchor < seriesStart && !sameSecond(anchor, seriesStart)) {
// The series has moved past it, so a tick has nothing left to close;
// a reopen goes to the copy that occurrence left.
when {
override != null -> writeInstance(master, override, anchor, close)
!closing -> copyAt(master, anchor)?.let { copy ->
saveRow(copy, close(copy))
writes.onWritten(setOf(copy.listId))
}
}
return
}
if (master.isClosed) {
// A series ends with its last occurrence; reopening that one reopens the
// series. An override reopens alone while other occurrences are closed
// only through the master, which reopening it would reopen too.
val reopensSeries = !closing && (override == null || !closedThroughMaster(master, anchor))
if (reopensSeries) closeSeries(master, override, close) else writeInstance(master, override, anchor, close)
return
}
if (!closing || !advance(master, anchor, override, close)) writeInstance(master, override, anchor, close)
}
tasks.override(taskId, occurrenceStart)?.let {
tasks.update(write(it))
writes.onWritten(setOf(master.listId))
return
/**
* Writes [edited] over [current]. A copy whose only change is that it
* reopens moves its series back instead; any other edit, [changedBesides]
* included, makes it a task of its own, and it no longer stands for the
* series' occurrence. So does any edit to an occurrence detached with it.
*/
private fun saveRow(current: TaskEntity, edited: TaskEntity, changedBesides: Boolean = false) {
advanced.unlinkDetached(current.id)
val record = advanced.forCopy(current.id)?.takeIf { it.undoable }
if (record != null) {
val unchanged = !changedBesides && sameApartFromState(current, edited)
if (unchanged && current.isClosed && !edited.isClosed && reopenCopy(current, edited, record)) return
if (!unchanged || !edited.isClosed) advanced.keepAsOwnTask(current.id)
}
tasks.update(edited)
}
/**
* Nothing a user sees changed but its state — what saving a reopened copy
* from the editor amounts to. Compared as the editor writes them back:
* trimmed, blank as none, tags normalised.
*/
private fun sameApartFromState(a: TaskEntity, b: TaskEntity): Boolean {
fun text(value: String?) = value?.trim()?.ifEmpty { null }
return text(a.title) == text(b.title) && text(a.description) == text(b.description) &&
text(a.location) == text(b.location) && text(a.url) == text(b.url) &&
a.priority == b.priority && a.dtstart == b.dtstart && a.due == b.due && a.isAllDay == b.isAllDay &&
a.listId == b.listId && a.parentId == b.parentId && a.rrule == b.rrule && a.rdate == b.rdate &&
normalizeTags(a.categories) == normalizeTags(b.categories) && a.color == b.color
}
/** Whether an occurrence of [master] other than [except] has no override, so its state is the master's. */
private fun closedThroughMaster(master: TaskEntity, except: Instant): Boolean {
val spec = master.recurrenceSpec() ?: return false
val overridden = tasks.overrides(master.id).mapNotNull { it.recurrenceId?.let(::wholeSecond) }.toSet()
return expandFrom(spec, maxOccurrences = overridden.size + 2)
.any { !sameSecond(it, except) && wholeSecond(it) !in overridden }
}
/** [close] applied to the master — and to [override], its last occurrence — which ends the series. */
private fun closeSeries(master: TaskEntity, override: TaskEntity?, close: (TaskEntity) -> TaskEntity) {
tasks.update(close(master))
override?.let { tasks.update(close(it)) }
writes.onWritten(setOf(master.listId))
}
/**
* Moves [master] past [anchor] when that is its first open occurrence; false
* when it is not, so the caller writes an override. Series with `RDATE`s keep
* the override model (anchoring on one would re-align the `RRULE`), and so do
* recurring subtasks, whose copies would pile up under the parent.
*/
private fun advance(master: TaskEntity, anchor: Instant, override: TaskEntity?, close: (TaskEntity) -> TaskEntity): Boolean {
val rule = master.rrule?.takeIf { it.isNotBlank() } ?: return false
if (!master.rdate.isNullOrBlank() || master.parentId != null) return false
val spec = master.recurrenceSpec() ?: return false
val overrides = tasks.overrides(master.id).associateBy { it.recurrenceId?.epochSeconds }
fun closedAt(at: Instant) = overrides[at.epochSeconds]?.isClosed == true
val leading = expandFrom(spec, maxOccurrences = overrides.values.count { it.isClosed } + 2)
val index = leading.indexOfFirst { sameSecond(it, anchor) }
if (index < 0 || leading.take(index).any { !closedAt(it) }) return false
var nextIndex = index + 1
while (nextIndex < leading.size && closedAt(leading[nextIndex])) nextIndex++
val next = leading.getOrNull(nextIndex) ?: return true.also { closeSeries(master, override, close) }
val now = clock.now()
val zone = master.timezone ?: zone()
val detached = (leading.take(index) + leading.subList(index + 1, nextIndex)).map { at ->
at to detach(overrides.getValue(at.epochSeconds), now)
}
// Expansion drops the anchor's sub-second part; the series keeps it.
val newAnchor = next + (spec.anchor - wholeSecond(spec.anchor))
val spent = spentBefore(spec, next)
val (start, due) = occurrenceTimes(master, newAnchor)
val ruleAfter = TaskFormWriter.remainingCount(rule, spent)
tasks.update(
master.copy(
dtstart = start,
due = due,
rrule = ruleAfter,
exdate = TaskFormWriter.valuesFrom(master.exdate, newAnchor, zone),
status = TaskStatus.NEEDS_ACTION,
percentComplete = null,
completedAt = null,
lastModified = now,
isDirty = true,
),
)
val (reset, resetLists) = resetSubtasks(master, newAnchor, now)
val copyId = if (override != null) {
detach(override, now, close)
} else {
val (copyStart, copyDue) = occurrenceTimes(master, anchor)
val id = tasks.insert(close(asCopy(master, now).copy(dtstart = copyStart, due = copyDue)))
alarms.copyToTask(master.id, id, sameResource = false)
id
}
advanced.at(master.id, wholeSecond(anchor))?.let { advanced.delete(it.copyId) }
advanced.pruneTickedBefore(master.id, now - RECORD_HORIZON)
advanced.insert(
AdvancedOccurrenceEntity(
copyId = copyId,
masterId = master.id,
anchor = anchor,
advancedTo = newAnchor,
priorDtstart = master.dtstart,
priorDue = master.due,
priorStatus = master.status,
priorPercentComplete = master.percentComplete,
priorCompletedAt = master.completedAt,
spent = spent,
ruleAfter = ruleAfter,
droppedExdates = AdvancedOccurrenceEntity.encode(TaskFormWriter.instantsBefore(master.exdate, newAnchor, zone)),
fromOverride = override != null,
resetSubtasks = SubtaskReset.encode(reset),
),
)
advanced.insertDetached(detached.map { (at, id) -> AdvancedDetachedEntity(copyId = copyId, anchor = at, taskId = id) })
writes.onWritten(setOf(master.listId) + resetLists)
return true
}
/**
* Resets what [SubtaskReset] picks under [master] for the series' next
* occurrence at [newAnchor], dated ones moved along; the lists it wrote to.
* A copy a provider tick of a recurring sub-task left, synced in, is known by
* its marker while it stays under the parent the marker names.
*/
private fun resetSubtasks(master: TaskEntity, newAnchor: Instant, now: Instant): Pair<List<ResetSubtask>, Set<Long>> {
val floating = ZoneId.of(zone())
val shift = master.recurrenceAnchor?.let {
SeriesShift(it.toEpochMilliseconds(), newAnchor.toEpochMilliseconds(), dateZone(master.isAllDay, master.timezone, floating), floating)
}
val loaded = HashMap<Long, TaskEntity>().apply { put(master.id, master) }
val reset = SubtaskReset.toReset(master.id, shift) { id ->
val parentUid = loaded[id]?.uid
tasks.subtasks(id).map { row ->
val sub = row.task.also { loaded[it.id] = it }
val copy = SubtaskReset.isCopyOf(SubtaskReset.markerParentFromResidue(sub.unknownProperties), parentUid)
SubtaskNode(sub.id, sub.isRecurring || copy, sub.progress, sub.dates, sub.isAllDay, sub.timezone)
}
}
val lists = reset.mapNotNull { entry ->
loaded[entry.id]?.also { tasks.update(it.withProgress(SubtaskState.FRESH, entry.dates, now)) }?.listId
}
return reset to lists.toSet()
}
/** What [resetSubtasks] reset for [record], back as it was where nothing changed it since. */
private fun restoreSubtasks(record: AdvancedOccurrenceEntity, now: Instant): Set<Long> {
val entries = SubtaskReset.decode(record.resetSubtasks)
val current = entries.mapNotNull { entry -> tasks.entity(entry.id)?.takeUnless { it.isDeleted } }.associateBy { it.id }
val outcome = SubtaskReset.toRestore(entries) { id -> current[id]?.let { SubtaskNow(it.progress, it.dates, it.isAllDay) } }
if (outcome.changed.isNotEmpty()) Log.i(TAG, "Sub-tasks ${outcome.changed} changed since the reset and keep their state")
return outcome.restore.mapNotNull { entry ->
current[entry.id]?.also { tasks.update(it.withProgress(entry.prior, entry.priorDates, now)) }?.listId
}.toSet()
}
private val TaskEntity.progress get() = SubtaskState(status, percentComplete, completedAt?.toEpochMilliseconds())
private val TaskEntity.dates get() = SubtaskDates(dtstart?.toEpochMilliseconds(), due?.toEpochMilliseconds())
/** [state] onto the row, and [dates] unless they are unknown. */
private fun TaskEntity.withProgress(state: SubtaskState, dates: SubtaskDates?, now: Instant) = copy(
status = state.status,
percentComplete = state.percent,
completedAt = state.completedAt?.let(Instant::fromEpochMilliseconds),
dtstart = if (dates == null) dtstart else dates.start?.let(Instant::fromEpochMilliseconds),
due = if (dates == null) due else dates.due?.let(Instant::fromEpochMilliseconds),
lastModified = now,
isDirty = true,
)
/** Occurrences `COUNT` has spent before [cut]: counted before `EXDATE` (RFC 5545 §3.8.5.3). */
private fun spentBefore(spec: RecurrenceSpec, cut: Instant): Int = RecurrenceExpander.expand(
spec.copy(exdate = null),
ExpansionWindow(from = wholeSecond(spec.anchor), until = cut, maxOccurrences = SPLIT_COUNT_CEILING),
).size
/**
* Turns the override [row] into a task of its own, in place so its id keeps
* working: a new UID, unlinked from the series, a resource of its own — and
* so its alarms are fresh copies, not the series' ones.
*/
private fun detach(row: TaskEntity, now: Instant, close: (TaskEntity) -> TaskEntity = { it }): Long {
tasks.update(close(asCopy(row, now).copy(id = row.id)))
refreshAlarms(row.id)
return row.id
}
private fun refreshAlarms(taskId: Long) {
val current = alarms.forTask(taskId)
alarms.deleteForTask(taskId)
current.forEach { alarms.insert(it.freshCopy(taskId)) }
}
/** [source] as a standalone task: no series fields, never uploaded. */
private fun asCopy(source: TaskEntity, now: Instant): TaskEntity = source.copy(
id = 0,
uid = UUID.randomUUID().toString(),
masterId = null,
recurrenceId = null,
href = null,
etag = null,
rrule = null,
rdate = null,
exdate = null,
createdAt = now,
sequence = 0,
isDeleted = false,
isDirty = true,
unknownProperties = seriesFreeText(source.unknownProperties),
)
/**
* Undoes [advance] for the copy [row] while the series still sits where it
* moved it under the same rule, and the copy is still just that — same list,
* nothing filed under it. The anchor goes back, `COUNT` gets back what it
* spent, and the dropped `EXDATE`s return in the list's own shape; any other
* edit made since stays. The occurrence takes [reopened]'s state: as a plain
* occurrence again, or as an override where that state is its own. The
* occurrences detached with it become overrides again, or are excluded where
* their row is gone or has a life of its own. False when it cannot, so the
* caller reopens the copy as a task of its own.
*/
private fun reopenCopy(row: TaskEntity, reopened: TaskEntity, record: AdvancedOccurrenceEntity): Boolean {
val master = tasks.entity(record.masterId)?.takeIf {
!it.isDeleted && !it.isClosed && row.listId == it.listId && sameRule(it.rrule, record.ruleAfter) &&
it.recurrenceAnchor?.let { a -> sameSecond(a, record.advancedTo) } == true
}
if (master == null || tasks.subtasks(row.id).isNotEmpty()) return false
val now = clock.now()
val zone = master.timezone ?: zone()
val synced = lists.entity(master.listId)?.accountId != null
val (start, due) = occurrenceTimes(master, record.priorDtstart ?: record.priorDue ?: record.anchor)
var back = master.copy(
dtstart = start,
due = due,
rrule = master.rrule?.let { TaskFormWriter.remainingCount(it, -record.spent) },
status = if (master.status == TaskStatus.NEEDS_ACTION) record.priorStatus else master.status,
percentComplete = master.percentComplete ?: record.priorPercentComplete,
completedAt = master.completedAt ?: record.priorCompletedAt,
lastModified = now,
isDirty = true,
)
record.dropped.forEach { back = TaskFormWriter.excepting(back, it, now, zone, dirty = synced) }
// A detached row given a parent or subtasks since has a life of its own, and stays out.
fun returns(task: TaskEntity?) = task != null && !task.isDeleted && task.listId == master.listId &&
task.parentId == master.parentId && tasks.subtasks(task.id).isEmpty()
val detached = advanced.detachedFor(row.id).map { it.anchor to it.taskId?.let(tasks::entity) }
detached.forEach { (at, task) -> if (!returns(task)) back = TaskFormWriter.excepting(back, at, now, zone, dirty = synced) }
tasks.update(back)
detached.forEach { (at, task) -> if (returns(task)) toOverride(task!!, back, at, now) }
advanced.delete(row.id)
val plain = reopened.status == TaskStatus.NEEDS_ACTION && reopened.percentComplete == null
if (record.fromOverride || !plain) toOverride(reopened, back, record.anchor, now)
else deleteTask(row.id)
writes.onWritten(setOf(master.listId) + restoreSubtasks(record, now))
return true
}
/** The same `RRULE` however a server re-serialised it: parts in any order and case. */
private fun sameRule(a: String?, b: String?): Boolean {
fun parts(rule: String?) = rule?.trim()?.removePrefix("RRULE:")?.split(';')
?.map { it.trim().uppercase() }?.filter { it.isNotEmpty() }?.toSet()
return parts(a) == parts(b)
}
/** [row], a detached occurrence, back as [master]'s override for [anchor]. */
private fun toOverride(row: TaskEntity, master: TaskEntity, anchor: Instant, now: Instant) {
val back = row.copy(
uid = master.uid,
masterId = master.id,
recurrenceId = anchor,
href = null,
etag = null,
isDirty = true,
lastModified = now,
unknownProperties = seriesFreeText(row.unknownProperties),
)
if (row.href == null) {
tasks.update(back)
refreshAlarms(row.id)
} else {
// Already uploaded as a resource of its own: that one goes, a fresh override comes.
val id = tasks.insert(back.copy(id = 0))
alarms.copyToTask(row.id, id, sameResource = false)
deleteTask(row.id)
}
}
/** The copy [advance] left for [master]'s occurrence at [anchor], if it is still there. */
private fun copyAt(master: TaskEntity, anchor: Instant): TaskEntity? =
advanced.at(master.id, wholeSecond(anchor))?.let { tasks.entity(it.copyId) }?.takeUnless { it.isDeleted }
/** The series' occurrences from its anchor on, at most [maxOccurrences]. */
private fun expandFrom(spec: RecurrenceSpec, maxOccurrences: Int): List<Instant> {
val from = wholeSecond(spec.anchor)
return RecurrenceExpander.expand(spec, ExpansionWindow(from = from, until = from + SERIES_LOOKAHEAD, maxOccurrences = maxOccurrences))
}
/**
* The residue without the series' own lines, as a new resource must not
* carry them. Lines inside a nested component (a `VALARM`'s `UID`) are its
* own and stay.
*/
private fun seriesFreeText(unknown: String?): String? {
var depth = 0
return unknown?.lines()?.filter { line ->
when {
line.startsWith("BEGIN:", ignoreCase = true) -> { depth++; true }
line.startsWith("END:", ignoreCase = true) -> { depth--; true }
else -> depth > 0 || (line.isNotBlank() && SERIES_LINES.none { line.startsWith(it, ignoreCase = true) })
}
}?.joinToString("\r\n")?.ifBlank { null }
}
/** Applies [write] to [override], or to a new one forked from [master] for [occurrenceStart]. */
private fun writeInstance(master: TaskEntity, override: TaskEntity?, occurrenceStart: Instant, write: (TaskEntity) -> TaskEntity) {
if (override != null) {
tasks.update(write(override))
} else {
val (start, due) = occurrenceTimes(master, occurrenceStart)
val id = tasks.insert(write(newOverride(master, master.id, occurrenceStart, start, due)))
alarms.copyToTask(master.id, id, sameResource = true)
}
val (start, due) = occurrenceTimes(master, occurrenceStart)
val id = tasks.insert(write(newOverride(master, taskId, occurrenceStart, start, due)))
alarms.copyToTask(taskId, id, sameResource = true)
writes.onWritten(setOf(master.listId))
}
@@ -608,7 +980,7 @@ class RoomTasksDataSource @Inject constructor(
if (lists.exists(edited.listId) == 0) throw TaskWriteFailedException("move task: no list ${edited.listId}")
move(current, edited)
}
else -> tasks.update(edited)
else -> saveRow(current, edited)
}
}
writes.onWritten(setOf(current.listId, edited.listId))
@@ -199,4 +199,8 @@ interface TaskDao {
*/
@Query("UPDATE tasks SET href = :href, etag = :etag, is_dirty = 0 WHERE id IN (:taskIds)")
fun markSynced(taskIds: List<Long>, href: String?, etag: String?): Int
/** Sets `is_dirty` again on [taskId] when it was written after [seen], its `last_modified` when uploaded. */
@Query("UPDATE tasks SET is_dirty = 1 WHERE id = :taskId AND last_modified IS NOT :seen")
fun dirtyIfChangedSince(taskId: Long, seen: Instant?)
}
@@ -120,18 +120,25 @@ object TaskFormWriter {
fun valuesBefore(values: String?, cut: Instant, zone: String): String? =
mapValues(values, zone) { it.takeIf { at -> at < cut } }
/** The instants of an `EXDATE`/`RDATE` list before [cut]; values it cannot read are left out. */
fun instantsBefore(values: String?, cut: Instant, zone: String): List<Instant> =
parsed(values, zone).mapNotNull { it.second }.filter { it < cut }
/** The values of an `EXDATE`/`RDATE` list at or after [cut]. */
fun valuesFrom(values: String?, cut: Instant, zone: String): String? =
mapValues(values, zone) { it.takeIf { at -> at >= cut } }
private fun mapValues(values: String?, zone: String, map: (Instant) -> Instant?): String? {
val list = values?.split(',')?.map(String::trim)?.filter(String::isNotEmpty) ?: return values
return list.mapNotNull { raw ->
val at = readValue(raw, zone) ?: return@mapNotNull raw
map(at)?.let { writeValueLike(raw, it, zone) }
if (values == null) return null
return parsed(values, zone).mapNotNull { (raw, at) ->
if (at == null) raw else map(at)?.let { writeValueLike(raw, it, zone) }
}.joinToString(",").ifEmpty { null }
}
/** Each value of an `EXDATE`/`RDATE` list with its instant, or null where it cannot be read. */
private fun parsed(values: String?, zone: String): List<Pair<String, Instant?>> =
values?.split(',')?.map(String::trim)?.filter(String::isNotEmpty)?.map { it to readValue(it, zone) }.orEmpty()
private fun readValue(raw: String, zone: String): Instant? = runCatching {
val z = java.time.ZoneId.of(zone)
val instant = when {
@@ -18,7 +18,7 @@ interface TaskListDao {
"""
SELECT l.*, a.display_name AS account_display_name, a.label AS account_label
FROM task_lists l LEFT JOIN accounts a ON a.id = l.account_id
ORDER BY a.display_name, l.sort_order, l.name
ORDER BY a.display_name COLLATE NOCASE, l.sort_order, l.name COLLATE NOCASE, l.name
"""
)
fun lists(): List<TaskListRow>
@@ -32,6 +32,10 @@ interface TaskListDao {
)
fun list(listId: Long): TaskListRow?
/** The highest position among device-only lists, -1 when there are none. */
@Query("SELECT COALESCE(MAX(sort_order), -1) FROM task_lists WHERE account_id IS NULL")
fun lastLocalSortOrder(): Int
@Query("SELECT * FROM task_lists WHERE id = :listId")
fun entity(listId: Long): TaskListEntity?
@@ -9,7 +9,7 @@ import java.util.UUID
import kotlin.time.Instant
/**
* Agendula's own task store. Four tables, designed from
* Agendula's own task store. Six tables, designed from
* what the app actually reads and writes plus RFC 5545's `VTODO`.
*
* Schemas are exported to `app/schemas/` and committed, so a future version can
@@ -21,8 +21,10 @@ import kotlin.time.Instant
TaskListEntity::class,
TaskEntity::class,
TaskAlarmEntity::class,
AdvancedOccurrenceEntity::class,
AdvancedDetachedEntity::class,
],
version = 4,
version = 6,
exportSchema = true,
)
@TypeConverters(Converters::class)
@@ -31,6 +33,7 @@ abstract class TasksDatabase : RoomDatabase() {
abstract fun tasks(): TaskDao
abstract fun alarms(): TaskAlarmDao
abstract fun accounts(): AccountDao
abstract fun advancedOccurrences(): AdvancedOccurrenceDao
companion object {
const val NAME = "agendula-tasks.db"
@@ -180,5 +183,33 @@ abstract class TasksDatabase : RoomDatabase() {
db.execSQL("ALTER TABLE accounts ADD COLUMN label TEXT")
}
}
/** v5 remembers how ticking an occurrence moved its series, so reopening it can move it back. */
val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"CREATE TABLE IF NOT EXISTS `advanced_occurrences` (`copy_id` INTEGER NOT NULL, `master_id` INTEGER NOT NULL, `anchor` INTEGER NOT NULL, `advanced_to` INTEGER NOT NULL, `prior_dtstart` INTEGER, `prior_due` INTEGER, `prior_status` INTEGER NOT NULL, `prior_percent_complete` INTEGER, `prior_completed_at` INTEGER, `spent` INTEGER NOT NULL, `rule_after` TEXT, `dropped_exdates` TEXT NOT NULL DEFAULT '', `from_override` INTEGER NOT NULL, `undoable` INTEGER NOT NULL DEFAULT 1, `settled` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`copy_id`), FOREIGN KEY(`copy_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`master_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
)
db.execSQL(
"CREATE INDEX IF NOT EXISTS `index_advanced_occurrences_master_id_anchor` ON `advanced_occurrences` (`master_id`, `anchor`)",
)
db.execSQL(
"CREATE TABLE IF NOT EXISTS `advanced_detached` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `copy_id` INTEGER NOT NULL, `anchor` INTEGER NOT NULL, `task_id` INTEGER, FOREIGN KEY(`copy_id`) REFERENCES `advanced_occurrences`(`copy_id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`task_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
)
db.execSQL(
"CREATE INDEX IF NOT EXISTS `index_advanced_detached_copy_id` ON `advanced_detached` (`copy_id`)",
)
db.execSQL(
"CREATE INDEX IF NOT EXISTS `index_advanced_detached_task_id` ON `advanced_detached` (`task_id`)",
)
}
}
/** v6 remembers which sub-tasks a moved series reset, so moving it back restores them (#160). */
val MIGRATION_5_6 = object : Migration(5, 6) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE advanced_occurrences ADD COLUMN `reset_subtasks` TEXT NOT NULL DEFAULT ''")
}
}
}
}
@@ -159,7 +159,9 @@ class ExternalImport @Inject constructor(
val taskDao = database.tasks()
val alarmDao = database.alarms()
val listIds = snapshot.lists.associate { list ->
// After the device-only lists already there, which the copies join.
val after = listDao.lastLocalSortOrder() + 1
val listIds = snapshot.lists.withIndex().associate { (position, list) ->
list.id to listDao.insert(
TaskListEntity(
name = list.name,
@@ -168,6 +170,7 @@ class ExternalImport @Inject constructor(
isVisible = list.isVisible,
isSynced = false,
owner = list.owner,
sortOrder = after + position,
),
)
}
@@ -37,6 +37,9 @@ data class TaskList(
enum class TaskStatus { NEEDS_ACTION, IN_PROCESS, COMPLETED, CANCELLED }
/** Completed or cancelled. */
val TaskStatus.isClosed: Boolean get() = this == TaskStatus.COMPLETED || this == TaskStatus.CANCELLED
/** iCalendar priority, bucketed for the UI. */
enum class Priority { NONE, LOW, MEDIUM, HIGH }
@@ -111,7 +114,7 @@ data class Task(
val isOccurrence: Boolean get() = seriesId != null && occurrenceStart != null
val isCompleted: Boolean get() = status == TaskStatus.COMPLETED
val isClosed: Boolean get() = status == TaskStatus.COMPLETED || status == TaskStatus.CANCELLED
val isClosed: Boolean get() = status.isClosed
val isSubtask: Boolean get() = parentId != null && parentId > 0
/** The task's own colour if set, else the list colour. */
val effectiveColor: Int get() = taskColor ?: listColor
@@ -12,6 +12,16 @@ import kotlin.time.Instant
private const val MILLIS_PER_SECOND = 1000L
private const val MILLIS_PER_DAY = 24L * 60 * 60 * 1000
/**
* The zone a task's dates are read in: UTC for an all-day one, whose dates are
* midnights there; else its [timeZone], or [floating] when it has none or an
* unknown one.
*/
fun dateZone(isAllDay: Boolean, timeZone: String?, floating: ZoneId = ZoneId.systemDefault()): ZoneId {
if (isAllDay) return ZoneId.of("UTC")
return timeZone?.let { runCatching { ZoneId.of(it) }.getOrNull() } ?: floating
}
/** The rule set of one task series, as stored. All strings are raw iCalendar values. */
data class RecurrenceSpec(
val rrule: String?,
@@ -133,11 +143,8 @@ object RecurrenceExpander {
return occurrences.indices.map { it - current }
}
private fun zoneOf(spec: RecurrenceSpec, floatingZone: ZoneId): TimeZone {
if (spec.isAllDay) return TimeZone.getTimeZone(ZoneId.of("UTC"))
val stored = spec.timeZone?.let { runCatching { ZoneId.of(it) }.getOrNull() }
return TimeZone.getTimeZone(stored ?: floatingZone)
}
private fun zoneOf(spec: RecurrenceSpec, floatingZone: ZoneId): TimeZone =
TimeZone.getTimeZone(dateZone(spec.isAllDay, spec.timeZone, floatingZone))
/**
* All-day series are date-anchored: pin the anchor to UTC midnight, as it is
+91
View File
@@ -763,4 +763,95 @@
<string name="widget_config_background">الخلفية</string>
<string name="widget_config_background_solid">سادة</string>
<string name="widget_config_theme">السمة</string>
<string name="app_name_tasks_alias">مهام</string>
<string name="list_where">أين</string>
<string name="settings_backup_subtitle">التصدير و الاستيراد</string>
<string name="error_reminder_without_date">اضبط تاريخ بدء أو استحقاق لاستخدام تذكير.</string>
<string name="reminder_at_start">في وقت البدء</string>
<string name="reminder_at_due_time">في وقت الاستحقاق</string>
<string name="reminder_on_start_date">عند موعد البدء</string>
<string name="reminder_on_due_date">عند موعد الاستحقاق</string>
<string name="reminder_at_time">%1$s عند %2$s</string>
<string name="reminder_anchor_start">من البدء</string>
<string name="reminder_anchor_due">من تاريخ الاستحقاق</string>
<string name="reminder_time">الوقت</string>
<string name="reminder_benefit_delivery_title">تنبيه عندما يكون الأمر مهمًا</string>
<string name="reminder_benefit_reversible_title">يمكنك تغييره في أي وقت</string>
<string name="settings_app_name_hint">اعرض Agendula كـ ”مهام“ في مشغّل التطبيقات الخاص بك. فقط الاسم في المشغّل يتغير؛ وقد ينتقل الرمز إلى مكان جديد. يُغلَق Agendula عندما تقوم بالتبديل، لذا أعِد فتحه من المشغّل.</string>
<string name="settings_allday_reminder_time_summary">العد التنازلي لمهام التي تستمر طوال اليوم عند %1$s</string>
<string name="settings_store_permission_denied_hint">مهام التطبيق الآخر تبقى غير قابلة للوصول حتى تسمح بالوصول. اضغط لفتح إعدادات التطبيق.</string>
<string name="settings_copy_counting">جارٍ حساب ما هناك لنسخه…</string>
<string name="settings_copy_confirm_empty">%1$s لا يحتوي على مهام لنسخها.</string>
<string name="settings_copy_nothing">لم يكن هناك شيء للنسخ</string>
<string name="export_hint">ملف .ics واحد لكل قائمة، قابل للقراءة بواسطة تطبيقات المهام والتقويم الأخرى. القوائم الموضوع عليها بعلامة صح تذهب إلى مجلد تختاره أنت، أو إلى ملف مضغوط (zip) واحد.</string>
<string name="export_no_lists">لا توجد قوائم للتصدير</string>
<string name="export_to_folder">الحفظ إلى مجلد</string>
<string name="export_to_zip">حفظ كملف مضغوط (zip)</string>
<plurals name="export_done">
<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="reminder_minutes_before_start">
<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="reminder_hours_before_start">
<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="reminder_days_before_start">
<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="reminder_weeks_before_start">
<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="settings_accounts_subtitle">مزامنة مهامك مع خادم CalDAV</string>
<string name="accounts_add">إضافة حسابًا</string>
<string name="accounts_sync_interval">المزامنة تلقائيًا</string>
<string name="accounts_sync_interval_manual">فقط عند تقوم بالمزامنة</string>
<string name="accounts_push_via">تصل التغييرات فورًا، عبر %1$s</string>
<string name="widget_unavailable">افتح Agendula لإنهاء الإعداد</string>
<string name="widget_today_all_done">انتهى كل شيء</string>
<string name="widget_config_title">إظهار في الأداة المصغرة</string>
<string name="widget_config_group_by_list">تجميع حسب القائمة</string>
<string name="widget_config_group_by_list_hint">إظهار مهام كل قائمة ضمن عنوانها الخاص</string>
<string name="widget_config_background_solid_hint">يملأ الأداة المصغّرة بلون سطح السمة</string>
<string name="widget_config_background_tinted_hint">أكثر نعومة، تعبئة لونية متناغمة</string>
<string name="widget_config_background_transparent">شفافة</string>
<string name="widget_config_background_transparent_hint">لا تعبئة — تبقى المهام قابلة للقراءة في حاوياتها الخاصة</string>
<string name="widget_config_row_color_neutral_hint">يستخدم كل صف نفس التعبئة اللونية المتناغمة</string>
<string name="widget_config_row_color_by_list">حسب القائمة</string>
<string name="widget_config_row_color_by_list_hint">كل صف متناغم باللون الخاص بقائمته</string>
<string name="widget_today_description">حلقة تقدم لمهام اليوم</string>
<string name="widget_up_next_description">مهمتك الوحيدة الأقرب لموعد الاستحقاق</string>
<string name="widget_lists_label">قوائم</string>
<string name="widget_lists_description">مربع اختصار لكل قائمة، مع عدد مرّات فتحه</string>
<string name="widget_week_label">أسبوع</string>
<string name="widget_week_description">شريط من سبعة أيام لعدد المهام</string>
<string name="widget_config_background_tinted">ملونة</string>
<string name="settings_theme_black">أسود</string>
<string name="settings_theme_black_summary">داكن مع خلفيات سوداء نقية، لشاشات AMOLED</string>
<string name="settings_app_name">اسم التطبيق</string>
</resources>
+14
View File
@@ -320,4 +320,18 @@
<string name="reminder_at_due">V čase úkolu</string>
<string name="reminder_on_the_day">V daný den</string>
<string name="settings_list_reminders_title">Upozornění pro jednotlivé seznamy</string>
<string name="shortcut_new_task">Nový úkol</string>
<string name="shortcut_today">Dnes</string>
<string name="qs_tile_new_task">Nový úkol</string>
<string name="sort_title">Seřadit podle</string>
<string name="view_options_title">Řazení a filtrování</string>
<string name="view_filter_tags">Filtrovat podle štítku</string>
<string name="view_filter_clear">Zrušit filtr</string>
<string name="sort_due">Termín splnění</string>
<string name="sort_title_alpha">Název</string>
<string name="sort_created">Nedávno přidané</string>
<string name="clear_completed_action">Zrušit</string>
<string name="undo">Zpět</string>
<string name="section_today">Dnes</string>
<string name="section_upcoming">Nadcházející</string>
</resources>
+7
View File
@@ -28,4 +28,11 @@
<string name="qs_tile_new_task">Новая задача</string>
<string name="lists_reorder">Переопределить список</string>
<string name="lists_reorder_done">Завершить переопределение</string>
<string name="app_name_tasks_alias">Задачи</string>
<string name="lists_reorder_handle">Перетащите, чтобы изменить порядок</string>
<string name="sort_title">Сортировать по</string>
<string name="view_options_title">Сортировать и фильтровать</string>
<string name="view_filter_tags">Фильтр по тегу</string>
<string name="view_filter_clear">Очистить фильтр</string>
<string name="sort_due">Срок выполнения</string>
</resources>
@@ -0,0 +1,56 @@
package de.jeanlucmakiola.agendula.data.tasks
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.domain.TaskList
import org.junit.jupiter.api.Test
class ListOrderTest {
private fun list(id: Long, name: String, account: String = "Radicale") = TaskList(
id = id, name = name, color = 0, accountName = account, accountType = "bitfire.at.davdroid",
isSynced = true, isVisible = true, owner = null,
)
@Test
fun `an order survives encoding`() {
val order = mapOf(3L to 0, 1L to 1, 2L to 2)
assertThat(ListOrder.decode(ListOrder.encode(order))).isEqualTo(order)
}
@Test
fun `nothing stored and garbage both decode to no order`() {
assertThat(ListOrder.decode(null)).isEmpty()
assertThat(ListOrder.decode("x=1,2,=3,4=y")).isEmpty()
}
@Test
fun `lists follow the stored order within their account (#136)`() {
val lists = listOf(list(1, "Groceries"), list(2, "Home"), list(3, "Work"), list(4, "Inbox", account = "Local"))
val order = ListOrder.reordered(listOf(4, 1, 2, 3), listOf(3, 1, 2))
assertThat(ListOrder.sorted(lists, order).map { it.id }).containsExactly(4L, 3L, 1L, 2L).inOrder()
}
@Test
fun `reordering one account leaves every other list in its slot`() {
// 7 and 8 are another account; 5 joined this one after the screen was drawn.
val order = ListOrder.reordered(listOf(7, 8, 1, 5, 2), listOf(2, 1))
assertThat(order).isEqualTo(mapOf(7L to 0, 8L to 1, 2L to 2, 5L to 3, 1L to 4))
assertThat(order.values.toSet()).hasSize(order.size)
}
@Test
fun `names sort without regard to case`() {
val lists = listOf(list(1, "Zeta"), list(2, "apple"))
assertThat(ListOrder.sorted(lists, emptyMap()).map { it.id }).containsExactly(2L, 1L).inOrder()
}
@Test
fun `a list without a position counts as the first, as in the own store`() {
val lists = listOf(list(1, "Zeta"), list(2, "Alpha"), list(3, "Mid"))
val order = mapOf(3L to 0, 1L to 1)
assertThat(ListOrder.sorted(lists, order).map { it.id }).containsExactly(2L, 3L, 1L).inOrder()
}
}
@@ -0,0 +1,24 @@
package de.jeanlucmakiola.agendula.data.tasks
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class SqlChunksTest {
@Test
fun `ids are split so no query binds more than the limit`() {
val ids = (1L..2_100L).toList()
val chunks = inSelections("_id", ids, filter = "deleted = ?", filterArgs = listOf("0"))
assertThat(chunks.map { it.second.size }).containsExactly(999, 999, 105).inOrder()
assertThat(chunks.flatMap { (_, args) -> args.dropLast(1) }).isEqualTo(ids.map(Long::toString))
assertThat(chunks.all { (_, args) -> args.last() == "0" }).isTrue()
assertThat(chunks.first().first).endsWith("?) AND (deleted = ?)")
}
@Test
fun `repeated ids are bound once, and none give no query`() {
assertThat(inSelections("_id", listOf(3L, 3L, 4L)).single().second).asList().containsExactly("3", "4")
assertThat(inSelections("_id", emptyList())).isEmpty()
}
}
@@ -0,0 +1,299 @@
package de.jeanlucmakiola.agendula.data.tasks
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
import org.junit.jupiter.api.Test
import java.time.ZoneId
import java.time.ZoneOffset
import kotlin.time.Instant
class SubtaskResetTest {
private val done = SubtaskState(TaskStatus.COMPLETED, 100, 1_000L)
private val halfway = SubtaskState(TaskStatus.IN_PROCESS, 50, null)
private val cancelled = SubtaskState(TaskStatus.CANCELLED, null, null)
private fun node(id: Long, state: SubtaskState = done, recurring: Boolean = false) = SubtaskNode(id, recurring, state)
private fun reset(vararg tree: Pair<Long, List<SubtaskNode>>): List<Long> {
val children = tree.toMap()
return SubtaskReset.toReset(1L) { children[it].orEmpty() }.map { it.id }
}
@Test
fun `completed and half-done sub-tasks of a routine start over, fresh ones are left (#160)`() {
val reopened = reset(1L to listOf(node(2), node(3, SubtaskState.FRESH), node(4, halfway)))
assertThat(reopened).containsExactly(2L, 4L)
}
@Test
fun `an open sub-task with progress counts as started`() {
assertThat(reset(1L to listOf(node(2, SubtaskState(TaskStatus.NEEDS_ACTION, 20, null))))).containsExactly(2L)
}
@Test
fun `a cancelled sub-task stays cancelled, what is under it still starts over`() {
assertThat(reset(1L to listOf(node(2, cancelled)), 2L to listOf(node(3)))).containsExactly(3L)
}
@Test
fun `nested sub-tasks start over too, also under a fresh one`() {
val reopened = reset(
1L to listOf(node(2, SubtaskState.FRESH)),
2L to listOf(node(3)),
3L to listOf(node(4)),
)
assertThat(reopened).containsExactly(3L, 4L)
}
@Test
fun `a recurring sub-task, or a copy a tick of one left, is left alone with what is under it`() {
val reopened = reset(
1L to listOf(node(2, recurring = true), node(3), node(5, recurring = true)),
2L to listOf(node(4)),
)
assertThat(reopened).containsExactly(3L)
}
@Test
fun `a provider copy a tick in Agendula left is left alone, a plain sub-task is not`() {
val copies = setOf(3L)
val rows = listOf(
SubtaskReset.providerNode(2, rrule = "FREQ=DAILY", rdate = null, copies = copies, state = done),
SubtaskReset.providerNode(3, rrule = null, rdate = null, copies = copies, state = done),
SubtaskReset.providerNode(4, rrule = null, rdate = "20261110T080000Z", copies = copies, state = done),
SubtaskReset.providerNode(5, rrule = " ", rdate = null, copies = copies, state = done),
)
assertThat(SubtaskReset.toReset(1L) { if (it == 1L) rows else emptyList() }.map { it.id }).containsExactly(5L)
}
@Test
fun `a cycle or the parent listed under itself is not walked twice`() {
val reopened = reset(
1L to listOf(node(1), node(2)),
2L to listOf(node(3)),
3L to listOf(node(2)),
)
assertThat(reopened).containsExactly(2L, 3L)
}
@Test
fun `what was reset survives encoding`() {
val reset = listOf(
ResetSubtask(2, done),
ResetSubtask(3, halfway, SubtaskDates(1L, 2L), SubtaskDates(11L, 12L)),
ResetSubtask(4, SubtaskState(TaskStatus.NEEDS_ACTION, 10, null), SubtaskDates(null, 5L), SubtaskDates(null, 15L)),
)
assertThat(SubtaskReset.decode(SubtaskReset.encode(reset))).isEqualTo(reset)
assertThat(SubtaskReset.decode("")).isEmpty()
assertThat(SubtaskReset.decode(null)).isEmpty()
assertThat(SubtaskReset.decode("x,COMPLETED,,;2,NOPE,,;3,IN_PROCESS")).isEmpty()
}
@Test
fun `a record from before dates were kept restores the state and leaves the dates`() {
val legacy = SubtaskReset.decode("2,COMPLETED,100,1000").single()
assertThat(legacy).isEqualTo(ResetSubtask(2, done, priorDates = null, dates = null))
val dated = SubtaskNow(SubtaskState.FRESH, SubtaskDates(at("2026-11-09T10:00:00Z"), null), isAllDay = false)
assertThat(SubtaskReset.toRestore(listOf(legacy)) { dated }.restore).containsExactly(legacy)
assertThat(SubtaskReset.encode(listOf(legacy))).isEqualTo("2,COMPLETED,100,1000,,,,")
}
private fun now(state: SubtaskState, dates: SubtaskDates = SubtaskDates.NONE, allDay: Boolean = false) = SubtaskNow(state, dates, allDay)
@Test
fun `moving back restores only the sub-tasks still as the reset left them`() {
val moved = SubtaskDates(20_000L, null)
val reset = listOf(
ResetSubtask(2, done),
ResetSubtask(3, halfway),
ResetSubtask(4, done),
ResetSubtask(5, done, SubtaskDates(10_000L, null), moved),
ResetSubtask(6, done, SubtaskDates(10_000L, null), moved),
)
val current = mapOf(
2L to now(SubtaskState.FRESH),
3L to now(done),
5L to now(SubtaskState.FRESH, moved),
// Re-dated since: the user's dates win.
6L to now(SubtaskState.FRESH, SubtaskDates(30_000L, null)),
)
val outcome = SubtaskReset.toRestore(reset, current::get)
assertThat(outcome.restore.map { it.id }).containsExactly(2L, 5L)
// 4 is gone: neither restored nor reported as changed.
assertThat(outcome.changed).containsExactly(3L, 6L)
}
@Test
fun `dates compare as a store keeps them, to the second or the day`() {
val reset = ResetSubtask(2, done, SubtaskDates.NONE, SubtaskDates(at("2026-11-09T10:00:00.250Z"), null))
val allDay = ResetSubtask(3, done, SubtaskDates.NONE, SubtaskDates(null, at("2026-11-10T00:00:00Z")))
fun restores(entry: ResetSubtask, stored: Long, isAllDay: Boolean) =
SubtaskReset.toRestore(listOf(entry)) { now(SubtaskState.FRESH, SubtaskDates(if (isAllDay) null else stored, if (isAllDay) stored else null), isAllDay) }
.restore.isNotEmpty()
assertThat(restores(reset, at("2026-11-09T10:00:00Z"), isAllDay = false)).isTrue()
assertThat(restores(reset, at("2026-11-09T10:00:01Z"), isAllDay = false)).isFalse()
assertThat(restores(allDay, at("2026-11-10T12:00:00Z"), isAllDay = true)).isTrue()
assertThat(restores(allDay, at("2026-11-11T00:00:00Z"), isAllDay = true)).isFalse()
}
private val berlin = ZoneId.of("Europe/Berlin")
private fun at(text: String) = java.time.Instant.parse(text).toEpochMilli()
@Test
fun `a dated sub-task keeps its wall-clock place to the routine across a DST change`() {
// A weekly routine at 09:00 Berlin moves from the last Monday of summer time to the first of winter time.
val shift = SeriesShift(at("2026-10-19T07:00:00Z"), at("2026-10-26T08:00:00Z"), berlin, floating = ZoneOffset.UTC)
assertThat(shift.apply(at("2026-10-20T08:00:00Z"), isAllDay = false, timeZone = "Europe/Berlin")).isEqualTo(at("2026-10-27T09:00:00Z"))
assertThat(shift.apply(at("2026-10-21T00:00:00Z"), isAllDay = true)).isEqualTo(at("2026-10-28T00:00:00Z"))
}
@Test
fun `an all-day sub-task follows the routine's change of date, also on a step under a day`() {
// An eight-hourly routine moves from 20:00 to 04:00 the next day in Berlin.
val shift = SeriesShift(at("2026-11-09T19:00:00Z"), at("2026-11-10T03:00:00Z"), berlin, floating = ZoneOffset.UTC)
assertThat(shift.apply(at("2026-11-09T00:00:00Z"), isAllDay = true)).isEqualTo(at("2026-11-10T00:00:00Z"))
// From 04:00 to 12:00 the date stays.
val sameDay = SeriesShift(at("2026-11-10T03:00:00Z"), at("2026-11-10T11:00:00Z"), berlin, floating = ZoneOffset.UTC)
assertThat(sameDay.apply(at("2026-11-10T00:00:00Z"), isAllDay = true)).isEqualTo(at("2026-11-10T00:00:00Z"))
}
@Test
fun `a timed sub-task without a zone of its own is read in the floating zone`() {
val shift = SeriesShift(at("2026-10-19T07:00:00Z"), at("2026-10-26T08:00:00Z"), berlin, floating = berlin)
assertThat(shift.apply(at("2026-10-20T08:00:00Z"), isAllDay = false)).isEqualTo(at("2026-10-27T09:00:00Z"))
}
@Test
fun `moving on shifts dated sub-tasks, fresh ones included, and leaves undated fresh ones`() {
val shift = SeriesShift(at("2026-11-02T08:00:00Z"), at("2026-11-09T08:00:00Z"), ZoneOffset.UTC, floating = ZoneOffset.UTC)
val nodes = listOf(
SubtaskNode(2, false, done, SubtaskDates(at("2026-11-02T10:00:00Z"), null)),
SubtaskNode(3, false, SubtaskState.FRESH, SubtaskDates(null, at("2026-11-03T00:00:00Z")), isAllDay = true),
SubtaskNode(4, false, SubtaskState.FRESH),
SubtaskNode(5, false, cancelled, SubtaskDates(at("2026-11-02T10:00:00Z"), null)),
)
val reset = SubtaskReset.toReset(1L, shift) { if (it == 1L) nodes else emptyList() }
assertThat(reset).containsExactly(
ResetSubtask(2, done, SubtaskDates(at("2026-11-02T10:00:00Z"), null), SubtaskDates(at("2026-11-09T10:00:00Z"), null)),
ResetSubtask(3, SubtaskState.FRESH, SubtaskDates(null, at("2026-11-03T00:00:00Z")), SubtaskDates(null, at("2026-11-10T00:00:00Z"))),
)
}
@Test
fun `a provider series' shift is read in its own zone`() {
val before = mapOf<String, Any?>(Tasks.DTSTART to at("2026-10-19T07:00:00Z"), Tasks.TZ to "Europe/Berlin", Tasks.IS_ALLDAY to 0L)
val after = before + (Tasks.DTSTART to at("2026-10-26T08:00:00Z"))
val shift = SubtaskReset.shiftOf(before, after, floating = ZoneOffset.UTC)!!
assertThat(shift.apply(at("2026-10-20T08:00:00Z"), isAllDay = false, timeZone = "Europe/Berlin")).isEqualTo(at("2026-10-27T09:00:00Z"))
}
@Test
fun `a provider series' rule set is read from its columns`() {
val state = mapOf<String, Any?>(
Tasks.DTSTART to at("2026-11-02T08:00:00Z"), Tasks.RRULE to "FREQ=WEEKLY", Tasks.EXDATE to null,
Tasks.IS_ALLDAY to 1L, Tasks.TZ to null,
)
val spec = SubtaskReset.specOf(state)!!
assertThat(spec.rrule).isEqualTo("FREQ=WEEKLY")
assertThat(spec.isAllDay).isTrue()
assertThat(SubtaskReset.specOf(state + (Tasks.RRULE to " "))!!.rrule).isNull()
assertThat(SubtaskReset.specOf(state + (Tasks.DTSTART to null))).isNull()
}
@Test
fun `the copy marker names the parent it was left under`() {
val json = SubtaskReset.copyMarkerJson("parent-\"1\"")
assertThat(SubtaskReset.markerParentFromJson(json)).isEqualTo("parent-\"1\"")
assertThat(SubtaskReset.markerParentFromJson(" [\"x-agendula-tick-copy\", \"p\"]")).isEqualTo("p")
assertThat(SubtaskReset.markerParentFromJson("[\"X-AGENDULA-TICK-COPYRIGHT\",\"p\"]")).isNull()
assertThat(SubtaskReset.markerParentFromJson(null)).isNull()
assertThat(SubtaskReset.markerParentFromResidue("X-OTHER:1\r\nX-AGENDULA-TICK-COPY:p\\,1")).isEqualTo("p,1")
assertThat(SubtaskReset.markerParentFromResidue("X-AGENDULA-TICK-COPY;X-P=1:p")).isEqualTo("p")
assertThat(SubtaskReset.markerParentFromResidue("X-AGENDULA-TICK-COPYRIGHT:p")).isNull()
assertThat(SubtaskReset.markerParentFromResidue(null)).isNull()
}
@Test
fun `a marked copy counts only under the parent it came from`() {
fun node(markerParent: String?, parentUid: String?) =
SubtaskReset.providerNode(2, null, null, emptySet(), markerParent = markerParent, parentUid = parentUid, state = done)
assertThat(node("routine", "routine").recurring).isTrue()
// Moved under another parent since: an ordinary completed sub-task there.
assertThat(node("routine", "other").recurring).isFalse()
assertThat(node(null, "routine").recurring).isFalse()
assertThat(node("routine", null).recurring).isFalse()
}
@Test
fun `a moved-on provider series resets with or without a copy, and its record is found by series and anchor`() {
val before = state(start) + (Tasks.RRULE to "FREQ=WEEKLY")
val after = state(start + week) + (Tasks.RRULE to "FREQ=WEEKLY")
assertThat(SubtaskReset.movedOn(before, after)).isTrue()
assertThat(SubtaskReset.movedOn(before, before)).isFalse()
val anchor = SubtaskReset.anchor(after)!!
val key = TickResets.key("org.dmfs.tasks", 4, anchor)
val record = SubtaskReset.encode(listOf(ResetSubtask(2, done)))
assertThat(key).isEqualTo("org.dmfs.tasks|4|${start + week}")
assertThat(TickResets.decode(TickResets.encode(1L, record))).isEqualTo(record)
assertThat(SubtaskReset.toRestore(SubtaskReset.decode(record)) { now(SubtaskState.FRESH) }.restore.map { it.id }).containsExactly(2L)
}
@Test
fun `a reset record expires with the tick horizon`() {
val now = 100L * 24 * 60 * 60 * 1000
val entries = mapOf(
"a|1|1" to TickResets.encode(now - 31L * 24 * 60 * 60 * 1000, "2,COMPLETED,,,,,,"),
"a|1|2" to TickResets.encode(now - 1, "3,COMPLETED,,,,,,"),
"garbage" to "x",
)
assertThat(TickResets.expire(entries, now)).containsExactly("a|1|1", "garbage")
assertThat(TickResets.decode("x")).isNull()
}
private val week = 7L * 24 * 60 * 60 * 1000
private val start = Instant.parse("2026-11-02T08:00:00Z").toEpochMilliseconds()
private fun state(dtstart: Long?, status: Int = TasksContract.STATUS_NEEDS_ACTION, exdate: String? = null, due: Long? = null) =
mapOf<String, Any?>(Tasks.DTSTART to dtstart, Tasks.DUE to due, Tasks.STATUS to status.toLong(), Tasks.EXDATE to exdate)
private fun spec(at: Long, rrule: String = "FREQ=WEEKLY") =
RecurrenceSpec(rrule = rrule, rdate = null, exdate = null, anchor = Instant.fromEpochMilliseconds(at), isAllDay = false, timeZone = "UTC")
@Test
fun `a provider series whose start moved later has moved on`() {
assertThat(SubtaskReset.movedOn(state(start), state(start + week), spec(start + week))).isTrue()
assertThat(SubtaskReset.movedOn(state(null, due = start), state(null, due = start + week), spec(start + week))).isTrue()
}
@Test
fun `ticking a later occurrence only adds an exception and moves nothing`() {
val after = state(start, exdate = "20261109T080000Z")
assertThat(SubtaskReset.movedOn(state(start), after, spec(start))).isFalse()
}
@Test
fun `a provider series ticked on its last occurrence is finished`() {
// OpenTasks deletes the master or drops its rule: there is no series state after.
assertThat(SubtaskReset.movedOn(state(start), null, null)).isFalse()
// Closed through its status.
assertThat(SubtaskReset.movedOn(state(start), state(start + week, TasksContract.STATUS_COMPLETED), spec(start + week))).isFalse()
// Dates moved, but the rule has nothing left from there.
val ended = spec(start + week, rrule = "FREQ=WEEKLY;UNTIL=20261103T080000Z")
assertThat(SubtaskReset.movedOn(state(start), state(start + week), ended)).isFalse()
assertThat(SubtaskReset.movedOn(state(start), state(start + week), null)).isFalse()
}
@Test
fun `a provider series moved earlier or without dates has not moved on`() {
assertThat(SubtaskReset.movedOn(state(start), state(start - week), spec(start - week))).isFalse()
assertThat(SubtaskReset.movedOn(state(null), state(null), spec(start))).isFalse()
assertThat(SubtaskReset.movedOn(null, state(start + week), spec(start + week))).isFalse()
}
}
@@ -7,6 +7,7 @@ import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.agendula.domain.TaskStatus
import org.junit.jupiter.api.Test
import kotlin.time.Instant
class TaskMapperTest {
@@ -34,7 +35,7 @@ class TaskMapperTest {
),
)
val task = TaskMapper.task(reader)
val task = mapTask(reader)
assertThat(task.taskId).isEqualTo(7L)
assertThat(task.listId).isEqualTo(3L)
@@ -52,7 +53,7 @@ class TaskMapperTest {
@Test
fun `an occurrence is identified by its recurrence-id anchor`() {
fun occurrence(columns: Map<String, Any?>) =
TaskMapper.task(MapColumnReader(columns + (Tasks.RRULE to "FREQ=DAILY")))
mapTask(MapColumnReader(columns), SeriesRule(rrule = "FREQ=DAILY", rdate = null))
// instance_original_time is the provider's own RECURRENCE-ID and wins.
val anchored = occurrence(
@@ -76,7 +77,7 @@ class TaskMapperTest {
@Test
fun `a non-recurring task has no occurrence anchor and keys by task id`() {
val task = TaskMapper.task(
val task = mapTask(
MapColumnReader(mapOf(Tasks.ID to 4L, Instances.INSTANCE_START to 500L)),
)
assertThat(task.occurrenceStart).isNull()
@@ -84,33 +85,81 @@ class TaskMapperTest {
}
@Test
fun `recurrence is detected from rrule when is_recurring is absent`() {
// tasks.org's bundled provider is DB 22 and has no `is_recurring` column;
// reading it alone would report the series as one-off and send its edits
// to the master row, re-anchoring the whole thing.
val task = TaskMapper.task(
MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.RRULE to "FREQ=WEEKLY;BYDAY=MO")),
fun `a series is detected from its master rule when the view masks it (#135)`() {
// tasks.org (DB 22): rule masked in the view, no `is_recurring`.
val task = mapTask(
MapColumnReader(
mapOf(
Tasks.ID to 90L,
Instances.TASK_ID to 9L,
Instances.INSTANCE_START to 1_000L,
Instances.INSTANCE_ORIGINAL_TIME to 1_000L,
),
),
SeriesRule(rrule = "FREQ=DAILY", rdate = null),
)
assertThat(task.isRecurring).isTrue()
assertThat(task.seriesId).isEqualTo(9L)
assertThat(task.occurrenceStart).isEqualTo(Instant.fromEpochMilliseconds(1_000L))
assertThat(task.isOccurrence).isTrue()
assertThat(task.recurrenceRule).isEqualTo("FREQ=DAILY")
}
@Test
fun `the master rule fills the editor even where is_recurring exists`() {
// OpenTasks (DB 23) flags the row but masks the rule too.
val task = mapTask(
MapColumnReader(mapOf(Tasks.ID to 90L, Instances.TASK_ID to 9L, Instances.IS_RECURRING to 1)),
SeriesRule(rrule = "FREQ=WEEKLY;BYDAY=MO", rdate = null),
)
assertThat(task.recurrenceRule).isEqualTo("FREQ=WEEKLY;BYDAY=MO")
}
@Test
fun `a tasks row without a rule is no series`() {
assertThat(TaskMapper.seriesRule(MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.RRULE to "")))).isNull()
assertThat(TaskMapper.seriesRule(MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.RDATE to "20260720T090000Z"))))
.isEqualTo(SeriesRule(rrule = null, rdate = "20260720T090000Z"))
}
@Test
fun `recurrence is detected from rdate alone`() {
val task = TaskMapper.task(
MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.RDATE to "20260720T090000Z")),
val task = mapTask(
MapColumnReader(mapOf(Tasks.ID to 1L)),
SeriesRule(rrule = null, rdate = "20260720T090000Z"),
)
assertThat(task.isRecurring).isTrue()
}
@Test
fun `an override belongs to its master's series`() {
// An edited occurrence is its own task row, without a rule of its own.
val task = mapTask(
MapColumnReader(
mapOf(
Tasks.ID to 95L,
Instances.TASK_ID to 12L,
Tasks.ORIGINAL_INSTANCE_ID to 9L,
Instances.INSTANCE_ORIGINAL_TIME to 2_000L,
),
),
SeriesRule(rrule = "FREQ=DAILY", rdate = null),
)
assertThat(task.taskId).isEqualTo(12L)
assertThat(task.seriesId).isEqualTo(9L)
assertThat(task.occurrenceStart).isEqualTo(Instant.fromEpochMilliseconds(2_000L))
assertThat(task.recurrenceRule).isEqualTo("FREQ=DAILY")
}
@Test
fun `a plain task is not recurring`() {
val task = TaskMapper.task(MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.TITLE to "One-off")))
val task = mapTask(MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.TITLE to "One-off")))
assertThat(task.isRecurring).isFalse()
}
@Test
fun `falls back to instance id when task_id missing, and list color when no task color`() {
val task = TaskMapper.task(
val task = mapTask(
MapColumnReader(mapOf(Tasks.ID to 9L, Tasks.LIST_COLOR to 0x111111)),
)
assertThat(task.taskId).isEqualTo(9L)
@@ -140,3 +189,5 @@ class TaskMapperTest {
assertThat(list.isLocal).isFalse()
}
}
private fun mapTask(r: ColumnReader, rule: SeriesRule? = null) = TaskMapper.withRule(TaskMapper.instance(r), rule)
@@ -102,14 +102,15 @@ class TaskWriteMapperTest {
}
@Test
fun `timed timestamps are written untouched`() {
fun `timed timestamps are kept to the second`() {
// The provider expands series in whole seconds.
val at = Instant.fromEpochMilliseconds(1_784_412_345_678L)
val values = TaskWriteMapper.taskValues(
TaskForm(title = "Standup", listId = 1L, start = at, due = at),
tzId = "Europe/Berlin",
)
assertThat(values[Tasks.DTSTART]).isEqualTo(1_784_412_345_678L)
assertThat(values[Tasks.DUE]).isEqualTo(1_784_412_345_678L)
assertThat(values[Tasks.DTSTART]).isEqualTo(1_784_412_345_000L)
assertThat(values[Tasks.DUE]).isEqualTo(1_784_412_345_000L)
}
@Test
@@ -0,0 +1,33 @@
package de.jeanlucmakiola.agendula.data.tasks
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class TickedCopiesTest {
private val now = 100L * 24 * 60 * 60 * 1000
private fun tick(copy: Long, daysAgo: Long) = Tick(copy, seriesId = 1, before = null, after = null, at = now - daysAgo * 24 * 60 * 60 * 1000)
@Test
fun `a tick past the horizon goes, and its copy stays known`() {
val ticks = mapOf(
"org.dmfs.tasks|1|10" to tick(copy = 7, daysAgo = 31),
"org.dmfs.tasks|1|20" to tick(copy = 8, daysAgo = 2),
"org.tasks.opentasks|5|10" to tick(copy = 9, daysAgo = 40),
"garbage" to null,
)
val (stale, known) = TickedCopies.expire(ticks, now)
assertThat(stale).containsExactly("org.dmfs.tasks|1|10", "org.tasks.opentasks|5|10", "garbage")
assertThat(known).containsExactly("org.dmfs.tasks", setOf(7L), "org.tasks.opentasks", setOf(9L))
}
@Test
fun `nothing expires inside the horizon`() {
val (stale, known) = TickedCopies.expire(mapOf("a|1|1" to tick(copy = 3, daysAgo = 29)), now)
assertThat(stale).isEmpty()
assertThat(known).isEmpty()
}
}
@@ -76,11 +76,12 @@ class FakeStore(rows: List<TaskEntity> = emptyList()) : SyncStore {
return id
}
override fun markSynced(taskIds: List<Long>, href: String?, eTag: String?) {
taskIds.forEach { id ->
val index = rows.indexOfFirst { it.id == id }
override fun markSynced(rows: List<TaskEntity>, href: String?, eTag: String?) {
rows.forEach { synced ->
val index = this.rows.indexOfFirst { it.id == synced.id }
if (index >= 0) {
rows[index] = rows[index].copy(href = href, etag = eTag, isDirty = false)
val current = this.rows[index]
this.rows[index] = current.copy(href = href, etag = eTag, isDirty = current.lastModified != synced.lastModified)
}
}
}
@@ -0,0 +1,6 @@
### تغييرات
- إنجاز مهمة متكررة ينقلها إلى موعدها التالي ويحتفظ بنسخة منجزة، فتراها التطبيقات الأخرى وواجهات الويب للخوادم منجزة.
### إصلاحات
- مع tasks.org تحتفظ المهام المتكررة بقاعدتها: تعديلها لم يعد يحذفها وإنجازها لم يعد ينهي السلسلة كلها.
- مع tasks.org وOpenTasks يعرض المحرر التكرار ويُحفظ ترتيب القوائم.
@@ -0,0 +1,6 @@
### Změny
- Odškrtnutí opakovaného úkolu jej posune na další termín a ponechá dokončenou kopii, takže jiné aplikace a webová rozhraní serverů jej vidí jako hotový.
### Opravy
- S tasks.org si opakované úkoly drží své pravidlo: úprava ho už neodstraní a odškrtnutí už neukončí celou sérii.
- S tasks.org a OpenTasks editor ukazuje opakování a pořadí seznamů zůstává zachováno.
@@ -0,0 +1,6 @@
### Geändert
- Eine wiederkehrende Aufgabe abzuhaken rückt sie zum nächsten Termin vor und behält eine erledigte Kopie, sodass andere Apps und Server-Weboberflächen sie als erledigt sehen.
### Behoben
- Mit tasks.org behalten wiederkehrende Aufgaben ihre Regel: Bearbeiten entfernt sie nicht mehr, Abhaken beendet nicht mehr die ganze Serie.
- Mit tasks.org und OpenTasks zeigt der Editor die Wiederholung, und die Reihenfolge der Listen bleibt erhalten.
@@ -0,0 +1,6 @@
### Changed
- Ticking off a repeating task moves it on to its next date and keeps a completed copy, so other apps and server web views see it as done.
### Fixed
- With tasks.org, repeating tasks keep their rule: editing one no longer removes it and ticking one off no longer ends the series.
- With tasks.org and OpenTasks, the editor shows the repeat rule and your list order is kept.
@@ -0,0 +1,6 @@
### Changed
- Ticking off a repeating task moves it on to its next date and keeps a completed copy, so other apps and server web views see it as done.
### Fixed
- With tasks.org, repeating tasks keep their rule: editing one no longer removes it and ticking one off no longer ends the series.
- With tasks.org and OpenTasks, the editor shows the repeat rule and your list order is kept.
@@ -0,0 +1,6 @@
### Modifications
- Cocher une tâche récurrente la passe à sa prochaine date et garde une copie terminée : les autres applis et les interfaces web des serveurs la voient comme faite.
### Corrections
- Avec tasks.org, les tâches récurrentes gardent leur règle : la modifier ne la supprime plus, la cocher ne termine plus toute la série.
- Avec tasks.org et OpenTasks, l’éditeur affiche la répétition et l’ordre des listes est conservé.
@@ -0,0 +1,6 @@
### Alterações
- Concluir uma tarefa recorrente a leva para a próxima data e mantém uma cópia concluída, para que outros apps e a web do servidor a vejam como feita.
### Correções
- Com o tasks.org, tarefas recorrentes mantêm a regra: editar não a remove mais e concluir não encerra mais a série inteira.
- Com tasks.org e OpenTasks, o editor mostra a repetição e a ordem das listas é mantida.
@@ -0,0 +1,6 @@
### Изменения
- Отметка повторяющейся задачи переносит её на следующую дату и сохраняет выполненную копию, так что другие приложения и веб-интерфейсы серверов видят её выполненной.
### Исправления
- С tasks.org повторяющиеся задачи сохраняют правило: правка больше его не удаляет, а отметка не завершает всю серию.
- С tasks.org и OpenTasks редактор показывает повторение, а порядок списков сохраняется.