Compare commits
5
Commits
v1.3.1
...
release/v1.3.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7afa2f41f0 | ||
|
|
4b1294341a | ||
|
|
2dfc676b8f | ||
|
|
109eda82b2 | ||
|
|
68495a936c |
No files matched your search
@@ -7,6 +7,24 @@ All notable changes to this project are documented here. The format follows
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
|
||||
@@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
+76
@@ -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)
|
||||
}
|
||||
}
|
||||
+361
@@ -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)
|
||||
}
|
||||
}
|
||||
+5
@@ -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
|
||||
|
||||
+18
-2
@@ -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 }
|
||||
|
||||
+625
-5
@@ -211,7 +211,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 +238,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 +256,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 +272,621 @@ 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 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 +1275,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)
|
||||
|
||||
+12
@@ -162,6 +162,18 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TEST_DB = "migration-test.db"
|
||||
}
|
||||
|
||||
+90
@@ -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)
|
||||
.build()
|
||||
|
||||
/**
|
||||
|
||||
+436
-80
@@ -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
|
||||
@@ -52,14 +55,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 +109,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> =
|
||||
@@ -107,23 +151,74 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
}
|
||||
} ?: emptyList()
|
||||
|
||||
/**
|
||||
* 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> = ids.distinct().chunked(MAX_SQL_ARGS - filterArgs.size).flatMap { chunk ->
|
||||
val selection = buildString {
|
||||
append("$idColumn IN (${chunk.joinToString(",") { "?" }})")
|
||||
if (filter != null) append(" AND ($filter)")
|
||||
}
|
||||
val args = (chunk.map(Long::toString) + filterArgs).toTypedArray()
|
||||
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 +289,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 +297,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)
|
||||
@@ -253,9 +348,10 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
if (!c.moveToFirst()) return@use null
|
||||
val r = CursorColumnReader(c)
|
||||
val anchor = r.getLong(Tasks.DTSTART) ?: r.getLong(Tasks.DUE) ?: return@use null
|
||||
val rule = TaskMapper.seriesRule(r)
|
||||
RecurrenceSpec(
|
||||
rrule = r.getString(Tasks.RRULE)?.ifBlank { null },
|
||||
rdate = r.getString(Tasks.RDATE),
|
||||
rrule = rule?.rrule,
|
||||
rdate = rule?.rdate,
|
||||
exdate = r.getString(Tasks.EXDATE),
|
||||
anchor = Instant.fromEpochMilliseconds(anchor),
|
||||
isAllDay = r.getBoolean(Tasks.IS_ALLDAY),
|
||||
@@ -290,7 +386,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 +398,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 +410,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 +456,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 +659,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 +701,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 +725,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 +733,133 @@ 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.
|
||||
*/
|
||||
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) {
|
||||
runCatching {
|
||||
detachedSince(authority, lastId)?.let { copy ->
|
||||
val after = before?.let { seriesState(authority, taskId) }?.takeIf { it != before }
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. 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
|
||||
if (seriesState(authority, series) != tick.after || subtaskIds(tick.copyId).isNotEmpty()) return false
|
||||
val restore = before.filter { (column, value) -> tick.after?.get(column) != value }
|
||||
val ops = arrayListOf(
|
||||
ContentProviderOperation.newUpdate(taskUri(authority, series)).withValues(restore.toContentValues()).build(),
|
||||
ContentProviderOperation.newDelete(taskUri(authority, tick.copyId)).build(),
|
||||
)
|
||||
resolver.applyBatch(authority, ops)
|
||||
ticks.remove(key)
|
||||
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?>? {
|
||||
if (seriesRules(listOf(taskId)).isEmpty()) return null
|
||||
return 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 { context.getSharedPreferences(TICKED_COPIES, 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 +930,25 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
/** What a tick changes on a series, and an undo changes back. */
|
||||
private val SERIES_STATE = arrayOf(
|
||||
Tasks.DTSTART, Tasks.DUE, Tasks.DURATION, Tasks.RRULE, Tasks.RDATE, Tasks.EXDATE,
|
||||
Tasks.STATUS, Tasks.COMPLETED, Tasks.PERCENT_COMPLETE,
|
||||
)
|
||||
|
||||
private const val TAG = "AndroidTasksDataSource"
|
||||
|
||||
/** No rule to look up: the provider says the row does not recur. */
|
||||
private const val NO_RULE = -1L
|
||||
|
||||
/** SQLite's default bound-argument limit on older Android releases. */
|
||||
private const val MAX_SQL_ARGS = 999
|
||||
@@ -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)
|
||||
|
||||
@@ -13,20 +13,23 @@ 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,13 +50,31 @@ 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)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,3 +140,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,84 @@
|
||||
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.
|
||||
*/
|
||||
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]. */
|
||||
internal class TickedCopies(private val prefs: () -> 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 }
|
||||
}
|
||||
|
||||
fun put(key: String, tick: Tick) {
|
||||
val stale = prefs().all.filter { (_, value) ->
|
||||
val at = (value as? String)?.let(::decode)?.at ?: 0L
|
||||
tick.at - at > HORIZON_MS
|
||||
}.keys
|
||||
prefs().edit().apply {
|
||||
stale.forEach(::remove)
|
||||
putString(key, encode(tick))
|
||||
}.apply()
|
||||
}
|
||||
|
||||
fun remove(key: String) {
|
||||
prefs().edit().remove(key).apply()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
+25
-27
@@ -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,131 @@
|
||||
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,
|
||||
) {
|
||||
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
|
||||
|
||||
+361
-54
@@ -6,13 +6,17 @@ 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 java.util.UUID
|
||||
import javax.inject.Inject
|
||||
@@ -28,6 +32,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
|
||||
|
||||
@@ -103,6 +113,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 +141,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 +245,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 +254,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 +269,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 +341,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 +364,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 +423,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 +475,354 @@ 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 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,
|
||||
),
|
||||
)
|
||||
advanced.insertDetached(detached.map { (at, id) -> AdvancedDetachedEntity(copyId = copyId, anchor = at, taskId = id) })
|
||||
writes.onWritten(setOf(master.listId))
|
||||
return 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))
|
||||
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 +915,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 = 5,
|
||||
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,26 @@ 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`)",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 редактор показывает повторение, а порядок списков сохраняется.
|
||||
Reference in new issue
Block a user