Compare commits

...
19 changed files with 1959 additions and 63 deletions

No files matched your search

+8
View File
@@ -7,6 +7,14 @@ All notable changes to this project are documented here. The format follows
## [Unreleased]
### Fixed
- Sub-tasks of a repeating task start over when ticking off its current
occurrence moves it on to its next date, as in tasks.org: completed and
half-done ones open again, and dated ones move along with the task. Before,
the next occurrence showed them already ticked. Unticking gives them back
their state and dates, and repeating sub-tasks keep their own schedule
(#160).
## [1.3.1] - 2026-11-09
### Changed
@@ -0,0 +1,775 @@
{
"formatVersion": 1,
"database": {
"version": 6,
"identityHash": "6c8058a22084f0af602bc2f23c949b3c",
"entities": [
{
"tableName": "accounts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `display_name` TEXT NOT NULL, `principal_url` TEXT, `home_set_url` TEXT, `username` TEXT, `last_sync_at` INTEGER, `last_sync_error` TEXT, `label` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "display_name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "principalUrl",
"columnName": "principal_url",
"affinity": "TEXT"
},
{
"fieldPath": "homeSetUrl",
"columnName": "home_set_url",
"affinity": "TEXT"
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT"
},
{
"fieldPath": "lastSyncAt",
"columnName": "last_sync_at",
"affinity": "INTEGER"
},
{
"fieldPath": "lastSyncError",
"columnName": "last_sync_error",
"affinity": "TEXT"
},
{
"fieldPath": "label",
"columnName": "label",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "task_lists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `account_id` INTEGER, `is_visible` INTEGER NOT NULL DEFAULT 1, `is_synced` INTEGER NOT NULL DEFAULT 1, `owner` TEXT, `is_read_only` INTEGER NOT NULL DEFAULT 0, `sort_order` INTEGER NOT NULL DEFAULT 0, `href` TEXT, `ctag` TEXT, `sync_token` TEXT, `is_dirty` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`account_id`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "account_id",
"affinity": "INTEGER"
},
{
"fieldPath": "isVisible",
"columnName": "is_visible",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "1"
},
{
"fieldPath": "isSynced",
"columnName": "is_synced",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "1"
},
{
"fieldPath": "owner",
"columnName": "owner",
"affinity": "TEXT"
},
{
"fieldPath": "isReadOnly",
"columnName": "is_read_only",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "sortOrder",
"columnName": "sort_order",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "href",
"columnName": "href",
"affinity": "TEXT"
},
{
"fieldPath": "ctag",
"columnName": "ctag",
"affinity": "TEXT"
},
{
"fieldPath": "syncToken",
"columnName": "sync_token",
"affinity": "TEXT"
},
{
"fieldPath": "isDirty",
"columnName": "is_dirty",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_task_lists_account_id",
"unique": false,
"columnNames": [
"account_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_task_lists_account_id` ON `${TABLE_NAME}` (`account_id`)"
}
],
"foreignKeys": [
{
"table": "accounts",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"account_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "tasks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `list_id` INTEGER NOT NULL, `uid` TEXT NOT NULL, `href` TEXT, `etag` TEXT, `title` TEXT, `description` TEXT, `location` TEXT, `url` TEXT, `color` INTEGER, `categories` TEXT NOT NULL DEFAULT '', `status` INTEGER NOT NULL DEFAULT 0, `percent_complete` INTEGER, `completed_at` INTEGER, `priority` INTEGER NOT NULL DEFAULT 0, `classification` INTEGER, `dtstart` INTEGER, `due` INTEGER, `duration` TEXT, `is_all_day` INTEGER NOT NULL DEFAULT 0, `timezone` TEXT, `rrule` TEXT, `rdate` TEXT, `exdate` TEXT, `recurrence_id` INTEGER, `master_id` INTEGER, `parent_id` INTEGER, `sort_order` INTEGER NOT NULL DEFAULT 0, `created_at` INTEGER, `last_modified` INTEGER, `sequence` INTEGER NOT NULL DEFAULT 0, `is_dirty` INTEGER NOT NULL DEFAULT 0, `is_deleted` INTEGER NOT NULL DEFAULT 0, `unknown_properties` TEXT, `reminders_off` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`list_id`) REFERENCES `task_lists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`master_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parent_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "listId",
"columnName": "list_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "href",
"columnName": "href",
"affinity": "TEXT"
},
{
"fieldPath": "etag",
"columnName": "etag",
"affinity": "TEXT"
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT"
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT"
},
{
"fieldPath": "location",
"columnName": "location",
"affinity": "TEXT"
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT"
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "INTEGER"
},
{
"fieldPath": "categories",
"columnName": "categories",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "percentComplete",
"columnName": "percent_complete",
"affinity": "INTEGER"
},
{
"fieldPath": "completedAt",
"columnName": "completed_at",
"affinity": "INTEGER"
},
{
"fieldPath": "priority",
"columnName": "priority",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "classification",
"columnName": "classification",
"affinity": "INTEGER"
},
{
"fieldPath": "dtstart",
"columnName": "dtstart",
"affinity": "INTEGER"
},
{
"fieldPath": "due",
"columnName": "due",
"affinity": "INTEGER"
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "TEXT"
},
{
"fieldPath": "isAllDay",
"columnName": "is_all_day",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "timezone",
"columnName": "timezone",
"affinity": "TEXT"
},
{
"fieldPath": "rrule",
"columnName": "rrule",
"affinity": "TEXT"
},
{
"fieldPath": "rdate",
"columnName": "rdate",
"affinity": "TEXT"
},
{
"fieldPath": "exdate",
"columnName": "exdate",
"affinity": "TEXT"
},
{
"fieldPath": "recurrenceId",
"columnName": "recurrence_id",
"affinity": "INTEGER"
},
{
"fieldPath": "masterId",
"columnName": "master_id",
"affinity": "INTEGER"
},
{
"fieldPath": "parentId",
"columnName": "parent_id",
"affinity": "INTEGER"
},
{
"fieldPath": "sortOrder",
"columnName": "sort_order",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER"
},
{
"fieldPath": "lastModified",
"columnName": "last_modified",
"affinity": "INTEGER"
},
{
"fieldPath": "sequence",
"columnName": "sequence",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isDirty",
"columnName": "is_dirty",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isDeleted",
"columnName": "is_deleted",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "unknownProperties",
"columnName": "unknown_properties",
"affinity": "TEXT"
},
{
"fieldPath": "remindersOff",
"columnName": "reminders_off",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_tasks_list_id_is_deleted",
"unique": false,
"columnNames": [
"list_id",
"is_deleted"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_list_id_is_deleted` ON `${TABLE_NAME}` (`list_id`, `is_deleted`)"
},
{
"name": "index_tasks_parent_id",
"unique": false,
"columnNames": [
"parent_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_parent_id` ON `${TABLE_NAME}` (`parent_id`)"
},
{
"name": "index_tasks_master_id_recurrence_id",
"unique": false,
"columnNames": [
"master_id",
"recurrence_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_master_id_recurrence_id` ON `${TABLE_NAME}` (`master_id`, `recurrence_id`)"
},
{
"name": "index_tasks_is_dirty",
"unique": false,
"columnNames": [
"is_dirty"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_is_dirty` ON `${TABLE_NAME}` (`is_dirty`)"
},
{
"name": "index_tasks_list_id_uid_recurrence_id",
"unique": true,
"columnNames": [
"list_id",
"uid",
"recurrence_id"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tasks_list_id_uid_recurrence_id` ON `${TABLE_NAME}` (`list_id`, `uid`, `recurrence_id`)"
}
],
"foreignKeys": [
{
"table": "task_lists",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"list_id"
],
"referencedColumns": [
"id"
]
},
{
"table": "tasks",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"master_id"
],
"referencedColumns": [
"id"
]
},
{
"table": "tasks",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"parent_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "task_alarms",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `task_id` INTEGER NOT NULL, `minutes_before` INTEGER NOT NULL, `reference` TEXT NOT NULL DEFAULT 'DUE', `message` TEXT, `uid` TEXT NOT NULL DEFAULT '', `raw` TEXT, FOREIGN KEY(`task_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "taskId",
"columnName": "task_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "minutesBefore",
"columnName": "minutes_before",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "reference",
"columnName": "reference",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'DUE'"
},
{
"fieldPath": "message",
"columnName": "message",
"affinity": "TEXT"
},
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "raw",
"columnName": "raw",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_task_alarms_task_id",
"unique": false,
"columnNames": [
"task_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_task_alarms_task_id` ON `${TABLE_NAME}` (`task_id`)"
}
],
"foreignKeys": [
{
"table": "tasks",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"task_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "advanced_occurrences",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`copy_id` INTEGER NOT NULL, `master_id` INTEGER NOT NULL, `anchor` INTEGER NOT NULL, `advanced_to` INTEGER NOT NULL, `prior_dtstart` INTEGER, `prior_due` INTEGER, `prior_status` INTEGER NOT NULL, `prior_percent_complete` INTEGER, `prior_completed_at` INTEGER, `spent` INTEGER NOT NULL, `rule_after` TEXT, `dropped_exdates` TEXT NOT NULL DEFAULT '', `from_override` INTEGER NOT NULL, `undoable` INTEGER NOT NULL DEFAULT 1, `settled` INTEGER NOT NULL DEFAULT 0, `reset_subtasks` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`copy_id`), FOREIGN KEY(`copy_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`master_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "copyId",
"columnName": "copy_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "masterId",
"columnName": "master_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "anchor",
"columnName": "anchor",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "advancedTo",
"columnName": "advanced_to",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "priorDtstart",
"columnName": "prior_dtstart",
"affinity": "INTEGER"
},
{
"fieldPath": "priorDue",
"columnName": "prior_due",
"affinity": "INTEGER"
},
{
"fieldPath": "priorStatus",
"columnName": "prior_status",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "priorPercentComplete",
"columnName": "prior_percent_complete",
"affinity": "INTEGER"
},
{
"fieldPath": "priorCompletedAt",
"columnName": "prior_completed_at",
"affinity": "INTEGER"
},
{
"fieldPath": "spent",
"columnName": "spent",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "ruleAfter",
"columnName": "rule_after",
"affinity": "TEXT"
},
{
"fieldPath": "droppedExdates",
"columnName": "dropped_exdates",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "fromOverride",
"columnName": "from_override",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "undoable",
"columnName": "undoable",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "1"
},
{
"fieldPath": "settled",
"columnName": "settled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "resetSubtasks",
"columnName": "reset_subtasks",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"copy_id"
]
},
"indices": [
{
"name": "index_advanced_occurrences_master_id_anchor",
"unique": false,
"columnNames": [
"master_id",
"anchor"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_advanced_occurrences_master_id_anchor` ON `${TABLE_NAME}` (`master_id`, `anchor`)"
}
],
"foreignKeys": [
{
"table": "tasks",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"copy_id"
],
"referencedColumns": [
"id"
]
},
{
"table": "tasks",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"master_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "advanced_detached",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `copy_id` INTEGER NOT NULL, `anchor` INTEGER NOT NULL, `task_id` INTEGER, FOREIGN KEY(`copy_id`) REFERENCES `advanced_occurrences`(`copy_id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`task_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "copyId",
"columnName": "copy_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "anchor",
"columnName": "anchor",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "taskId",
"columnName": "task_id",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_advanced_detached_copy_id",
"unique": false,
"columnNames": [
"copy_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_advanced_detached_copy_id` ON `${TABLE_NAME}` (`copy_id`)"
},
{
"name": "index_advanced_detached_task_id",
"unique": false,
"columnNames": [
"task_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_advanced_detached_task_id` ON `${TABLE_NAME}` (`task_id`)"
}
],
"foreignKeys": [
{
"table": "advanced_occurrences",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"copy_id"
],
"referencedColumns": [
"copy_id"
]
},
{
"table": "tasks",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"task_id"
],
"referencedColumns": [
"id"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6c8058a22084f0af602bc2f23c949b3c')"
]
}
}
@@ -14,6 +14,7 @@ import org.junit.Test
import org.junit.runner.RunWith
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
import kotlin.time.Instant
/**
@@ -700,6 +701,144 @@ class RoomTasksDataSourceTest {
assertThat(db.tasks().overrides(id)).hasSize(1)
}
@Test
fun movingARoutineOnReopensItsCompletedSubtasks() {
val at = now
val id = source.insertTask(form(title = "Routine"))
makeRecurring(id, at)
val done = source.insertTask(form(title = "Step").copy(parentId = id))
val nested = source.insertTask(form(title = "Nested").copy(parentId = done))
val cancelled = source.insertTask(form(title = "Skipped").copy(parentId = id))
val daily = source.insertTask(form(title = "Daily part").copy(parentId = id))
makeRecurring(daily, at, rule = "FREQ=DAILY")
source.setCompleted(done, completed = true)
source.setCompleted(nested, completed = true)
source.setCancelled(cancelled, cancelled = true)
source.setCompletedInstance(daily, at, completed = true)
source.setCompletedInstance(id, at, completed = true)
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 7.days)
assertThat(db.tasks().entity(done)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
assertThat(db.tasks().entity(done)!!.completedAt).isNull()
assertThat(db.tasks().entity(nested)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
assertThat(db.tasks().entity(cancelled)!!.status).isEqualTo(TaskStatus.CANCELLED)
// A recurring subtask has its own series, and its closed occurrence stays closed.
assertThat(db.tasks().overrides(daily).single().status).isEqualTo(TaskStatus.COMPLETED)
}
@Test
fun aHalfDoneSubtaskStartsOverToo() {
val at = now
val id = source.insertTask(form(title = "Routine"))
makeRecurring(id, at)
val step = source.insertTask(form(title = "Step", percentComplete = 40).copy(parentId = id))
db.tasks().update(db.tasks().entity(step)!!.copy(status = TaskStatus.IN_PROCESS, percentComplete = 40))
source.setCompletedInstance(id, at, completed = true)
assertThat(db.tasks().entity(step)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
assertThat(db.tasks().entity(step)!!.percentComplete).isNull()
}
@Test
fun movingARoutineBackRestoresTheSubtasksItReset() {
val at = now
val id = source.insertTask(form(title = "Routine"))
makeRecurring(id, at)
val done = source.insertTask(form(title = "Step").copy(parentId = id))
val touched = source.insertTask(form(title = "Touched since").copy(parentId = id))
source.setCompleted(done, completed = true)
source.setCompleted(touched, completed = true)
val completedAt = db.tasks().entity(done)!!.completedAt
source.setCompletedInstance(id, at, completed = true)
db.tasks().update(db.tasks().entity(touched)!!.copy(percentComplete = 30))
source.setCompletedInstance(id, at, completed = false)
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at)
assertThat(db.tasks().entity(done)!!.status).isEqualTo(TaskStatus.COMPLETED)
assertThat(db.tasks().entity(done)!!.completedAt).isEqualTo(completedAt)
// Changed after the reset: the user's state wins.
assertThat(db.tasks().entity(touched)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
assertThat(db.tasks().entity(touched)!!.percentComplete).isEqualTo(30)
}
@Test
fun aDatedSubtaskMovesWithItsRoutineAndBack() {
val at = now
val id = source.insertTask(form(title = "Routine"))
makeRecurring(id, at)
val step = source.insertTask(form(title = "Step", due = at + 1.days).copy(parentId = id))
val undated = source.insertTask(form(title = "Undated").copy(parentId = id))
source.setCompleted(step, completed = true)
val due = db.tasks().entity(step)!!.due!!
source.setCompletedInstance(id, at, completed = true)
// A week of wall-clock time: an hour either way across a DST change.
assertThat(db.tasks().entity(step)!!.due!! - due).isIn(com.google.common.collect.Range.closed(7.days - 1.hours, 7.days + 1.hours))
assertThat(db.tasks().entity(step)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
assertThat(db.tasks().entity(undated)!!.due).isNull()
source.setCompletedInstance(id, at, completed = false)
assertThat(db.tasks().entity(step)!!.due).isEqualTo(due)
assertThat(db.tasks().entity(step)!!.status).isEqualTo(TaskStatus.COMPLETED)
}
@Test
fun aMarkedCopyUnderARoutineIsLeftAlone() {
val at = now
val id = source.insertTask(form(title = "Routine"))
makeRecurring(id, at)
val routineUid = db.tasks().entity(id)!!.uid
val copy = source.insertTask(form(title = "Daily part").copy(parentId = id))
val elsewhere = source.insertTask(form(title = "Moved here").copy(parentId = id))
source.setCompleted(copy, completed = true)
source.setCompleted(elsewhere, completed = true)
db.tasks().update(db.tasks().entity(copy)!!.copy(unknownProperties = "X-AGENDULA-TICK-COPY:$routineUid"))
db.tasks().update(db.tasks().entity(elsewhere)!!.copy(unknownProperties = "X-AGENDULA-TICK-COPY:another-parent"))
source.setCompletedInstance(id, at, completed = true)
assertThat(db.tasks().entity(copy)!!.status).isEqualTo(TaskStatus.COMPLETED)
// Its marker names a parent it no longer sits under: an ordinary sub-task here.
assertThat(db.tasks().entity(elsewhere)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
}
@Test
fun aLegacyResetRecordRestoresTheStateAndLeavesTheDates() {
val at = now
val id = source.insertTask(form(title = "Routine"))
makeRecurring(id, at)
val step = source.insertTask(form(title = "Step", due = at + 1.days).copy(parentId = id))
source.setCompleted(step, completed = true)
source.setCompletedInstance(id, at, completed = true)
val record = db.advancedOccurrences().forMaster(id).single()
db.advancedOccurrences().insert(record.copy(resetSubtasks = "$step,COMPLETED,100,"))
val movedDue = db.tasks().entity(step)!!.due
source.setCompletedInstance(id, at, completed = false)
assertThat(db.tasks().entity(step)!!.status).isEqualTo(TaskStatus.COMPLETED)
assertThat(db.tasks().entity(step)!!.due).isEqualTo(movedDue)
}
@Test
fun aRoutineTickedOnItsLastOccurrenceLeavesItsSubtasksDone() {
val at = now
val id = source.insertTask(form(title = "Once more"))
makeRecurring(id, at, rule = "FREQ=WEEKLY;COUNT=1")
val step = source.insertTask(form(title = "Step").copy(parentId = id))
source.setCompleted(step, completed = true)
source.setCompletedInstance(id, at, completed = true)
assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.COMPLETED)
assertThat(db.tasks().entity(step)!!.status).isEqualTo(TaskStatus.COMPLETED)
}
@Test
fun aSeriesReshapedSinceIsNotMovedBack() {
val at = now
@@ -174,6 +174,25 @@ class TasksDatabaseMigrationTest {
}
}
@Test
fun addsAnEmptyResetSubtasksRecord() {
helper.createDatabase(TEST_DB, 5).use { db ->
db.execSQL("INSERT INTO task_lists (id, name, color) VALUES (1, 'Personal', 0)")
db.execSQL("INSERT INTO tasks (id, list_id, uid) VALUES (1, 1, 'a'), (2, 1, 'b')")
db.execSQL(
"INSERT INTO advanced_occurrences (copy_id, master_id, anchor, advanced_to, prior_status, spent, from_override) " +
"VALUES (2, 1, 0, 1, 0, 1, 0)",
)
}
helper.runMigrationsAndValidate(TEST_DB, 6, true, TasksDatabase.MIGRATION_5_6).use { db ->
db.query("SELECT reset_subtasks FROM advanced_occurrences").use { c ->
assertThat(c.moveToFirst()).isTrue()
assertThat(c.getString(0)).isEmpty()
}
}
}
private companion object {
const val TEST_DB = "migration-test.db"
}
@@ -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, TasksDatabase.MIGRATION_4_5)
.addMigrations(TasksDatabase.MIGRATION_1_2, TasksDatabase.MIGRATION_2_3, TasksDatabase.MIGRATION_3_4, TasksDatabase.MIGRATION_4_5, TasksDatabase.MIGRATION_5_6)
.build()
/**
@@ -25,6 +25,8 @@ import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.export.ExportTask
import de.jeanlucmakiola.agendula.domain.normalizeTags
import de.jeanlucmakiola.agendula.domain.statusFromInt
import de.jeanlucmakiola.agendula.domain.toInt
import de.jeanlucmakiola.agendula.domain.recurrence.ExpansionWindow
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
@@ -135,21 +137,8 @@ class AndroidTasksDataSource @Inject constructor(
* the window still counts. All columns and a lenient `_deleted`, as in
* [exportTasks], since the table's shape varies across providers.
*/
override fun subtaskIds(parentTaskId: Long): List<Long> = resolver.query(
TasksContract.tasksUri(authority()),
null,
"${Tasks.PARENT_ID} = ? AND (${Tasks.DELETED} IS NULL OR ${Tasks.DELETED} = 0)",
arrayOf(parentTaskId.toString()),
null,
)?.use { c ->
val r = CursorColumnReader(c)
buildList {
while (c.moveToNext()) {
if (r.getLong(Tasks.ORIGINAL_INSTANCE_ID) != null) continue
r.getLong(Tasks.ID)?.takeIf { it != parentTaskId }?.let(::add)
}
}
} ?: emptyList()
override fun subtaskIds(parentTaskId: Long): List<Long> =
childRows(authority(), parentTaskId, projection = null) { it.getLong(Tasks.ID) }
/**
* All columns and no sort: the instances view differs across provider
@@ -207,12 +196,7 @@ class AndroidTasksDataSource @Inject constructor(
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()
): List<T> = inSelections(idColumn, ids, filter, filterArgs).flatMap { (selection, args) ->
resolver.query(uri, projection, selection, args, sortOrder)?.use { c ->
val reader = CursorColumnReader(c)
buildList { while (c.moveToNext()) read(reader)?.let(::add) }
@@ -345,18 +329,7 @@ class AndroidTasksDataSource @Inject constructor(
arrayOf(Tasks.RRULE, Tasks.RDATE, Tasks.EXDATE, Tasks.DTSTART, Tasks.DUE, Tasks.IS_ALLDAY, Tasks.TZ),
null, null, null,
)?.use { c ->
if (!c.moveToFirst()) return@use null
val r = CursorColumnReader(c)
val anchor = r.getLong(Tasks.DTSTART) ?: r.getLong(Tasks.DUE) ?: return@use null
val rule = TaskMapper.seriesRule(r)
RecurrenceSpec(
rrule = rule?.rrule,
rdate = rule?.rdate,
exdate = r.getString(Tasks.EXDATE),
anchor = Instant.fromEpochMilliseconds(anchor),
isAllDay = r.getBoolean(Tasks.IS_ALLDAY),
timeZone = r.getString(Tasks.TZ),
)
if (c.moveToFirst()) TaskMapper.seriesSpec(CursorColumnReader(c)) else null
}
/** End the master's rule before [occurrenceStart], and drop the exceptions past it. */
@@ -753,6 +726,8 @@ class AndroidTasksDataSource @Inject constructor(
* Runs [write], and when it closed an occurrence the provider then detached
* into a task of its own, remembers that copy, so the occurrence can still be
* read and reopened, and how the series moved, so reopening can move it back.
* When the series moved on, its sub-tasks start over for the next occurrence,
* and [resets] remembers how they were (#160), with or without a copy.
*/
private fun rememberingCopy(taskId: Long, occurrenceStart: Instant, closing: Boolean, write: () -> Int): Int {
if (!closing) return write()
@@ -761,9 +736,18 @@ class AndroidTasksDataSource @Inject constructor(
val before = runCatching { seriesState(authority, taskId) }.getOrNull()
return write().also { rows ->
if (rows > 0) {
val after = before?.let {
runCatching { seriesState(authority, taskId) }
.onFailure { Log.w(TAG, "Reading the series after the tick failed", it) }
.getOrNull()?.takeIf { it != before }
}
if (before != null && after != null && SubtaskReset.movedOn(before, after)) {
runCatching { resetSubtasks(authority, taskId, before, after) }
.onFailure { Log.w(TAG, "Resetting the sub-tasks failed", it) }
}
runCatching {
detachedSince(authority, lastId)?.let { copy ->
val after = before?.let { seriesState(authority, taskId) }?.takeIf { it != before }
markCopy(authority, copy, taskId)
val tick = Tick(copy, taskId.takeIf { after != null }, before.takeIf { after != null }, after, System.currentTimeMillis())
ticks.put(ticks.key(authority, taskId, occurrenceStart), tick)
}
@@ -772,12 +756,143 @@ class AndroidTasksDataSource @Inject constructor(
}
}
/**
* Marks [copyId], left by ticking the recurring sub-task [seriesId], with the
* UID of the parent it sits under, where DAVx5 keeps and uploads unknown
* properties, so other installs know it too. A top-level series' copy sits
* under nothing a reset walks, so it is left unmarked.
*/
private fun markCopy(authority: String, copyId: Long, seriesId: Long) {
runCatching {
val parentId = resolver.query(taskUri(authority, seriesId), arrayOf(Tasks.PARENT_ID), null, null, null)
?.use { c -> if (c.moveToFirst() && !c.isNull(0)) c.getLong(0) else null } ?: return
val parentUid = uidOf(authority, parentId) ?: return
val values = mapOf(
Properties.TASK_ID to copyId,
Properties.MIMETYPE to SubtaskReset.UNKNOWN_PROPERTY_MIMETYPE,
Properties.DATA0 to SubtaskReset.copyMarkerJson(parentUid),
)
resolver.insert(TasksContract.propertiesUri(authority), values.toContentValues())
}.onFailure { Log.w(TAG, "Marking the ticked copy failed", it) }
}
private fun uidOf(authority: String, taskId: Long): String? =
resolver.query(taskUri(authority, taskId), arrayOf(Tasks.UID), null, null, null)
?.use { c -> if (c.moveToFirst()) c.getString(0) else null }
/** The parent UID each of [taskIds] carries a copy marker for. */
private fun markerParents(authority: String, taskIds: List<Long>): Map<Long, String> = runCatching {
queryByIds(
TasksContract.propertiesUri(authority),
arrayOf(Properties.TASK_ID, Properties.DATA0),
Properties.TASK_ID,
taskIds,
filter = "${Properties.MIMETYPE} = ?",
filterArgs = listOf(SubtaskReset.UNKNOWN_PROPERTY_MIMETYPE),
) { r ->
val id = r.getLong(Properties.TASK_ID) ?: return@queryByIds null
SubtaskReset.markerParentFromJson(r.getString(Properties.DATA0))?.let { id to it }
}.toMap()
}.onFailure { Log.w(TAG, "Reading the copy markers failed", it) }.getOrDefault(emptyMap())
/**
* Resets what [SubtaskReset] picks under the series [seriesId], dated ones
* moved as the series moved from [before] to [after], and records it in
* [resets] first, under the anchor the series moved to.
*/
private fun resetSubtasks(authority: String, seriesId: Long, before: Map<String, Any?>, after: Map<String, Any?>) {
val copies = ticks.copies(authority)
val uids = HashMap<Long, String?>().apply { put(seriesId, uidOf(authority, seriesId)) }
val reset = SubtaskReset.toReset(seriesId, SubtaskReset.shiftOf(before, after)) { id ->
val rows = childRows(authority, id, SUBTASK_STATE) { r ->
r.getLong(Tasks.ID)?.let { rowId ->
uids[rowId] = r.getString(Tasks.UID)
// Read now: the cursor is closed by the time the markers are in.
val rrule = r.getString(Tasks.RRULE)
val rdate = r.getString(Tasks.RDATE)
val state = subtaskState(r)
val dates = SubtaskDates(r.getLong(Tasks.DTSTART), r.getLong(Tasks.DUE))
val allDay = r.getBoolean(Tasks.IS_ALLDAY)
val tz = r.getString(Tasks.TZ)
rowId to { markerParent: String? ->
SubtaskReset.providerNode(
rowId, rrule, rdate, copies, markerParent = markerParent, parentUid = uids[id],
state = state, dates = dates, isAllDay = allDay, timeZone = tz,
)
}
}
}
val markers = markerParents(authority, rows.map { it.first })
rows.map { (rowId, node) -> node(markers[rowId]) }
}
if (reset.isEmpty()) return
SubtaskReset.anchor(after)?.let { resets.put(authority, seriesId, it, SubtaskReset.encode(reset), System.currentTimeMillis()) }
val ops = reset.map { subtaskOp(authority, it.id, SubtaskState.FRESH, it.dates?.takeIf { d -> d != it.priorDates }) }
resolver.applyBatch(authority, ArrayList(ops))
}
private fun subtaskState(r: ColumnReader) =
SubtaskState(statusFromInt(r.getInt(Tasks.STATUS)), r.getInt(Tasks.PERCENT_COMPLETE), r.getLong(Tasks.COMPLETED))
/** [state] onto sub-task [id], and [dates] when they change. */
private fun subtaskOp(authority: String, id: Long, state: SubtaskState, dates: SubtaskDates?): ContentProviderOperation {
val values = buildMap<String, Any?> {
put(Tasks.STATUS, state.status.toInt())
put(Tasks.PERCENT_COMPLETE, state.percent)
put(Tasks.COMPLETED, state.completedAt)
if (dates != null) {
put(Tasks.DTSTART, dates.start)
put(Tasks.DUE, dates.due)
}
}
return ContentProviderOperation.newUpdate(taskUri(authority, id)).withValues(values.toContentValues()).build()
}
/** The reset sub-tasks of [reset] that [SubtaskReset.toRestore] gives back their state and dates. */
private fun subtaskRestore(authority: String, reset: String?): List<ContentProviderOperation> {
val entries = SubtaskReset.decode(reset)
if (entries.isEmpty()) return emptyList()
val current = queryByIds(
TasksContract.tasksUri(authority),
SUBTASK_STATE,
Tasks.ID,
entries.map { it.id },
filter = "${Tasks.DELETED} IS NULL OR ${Tasks.DELETED} = 0",
) { r ->
r.getLong(Tasks.ID)?.let {
it to SubtaskNow(subtaskState(r), SubtaskDates(r.getLong(Tasks.DTSTART), r.getLong(Tasks.DUE)), r.getBoolean(Tasks.IS_ALLDAY))
}
}.toMap()
val outcome = SubtaskReset.toRestore(entries, current::get)
if (outcome.changed.isNotEmpty()) Log.i(TAG, "Sub-tasks ${outcome.changed} changed since the reset and keep their state")
return outcome.restore.map { subtaskOp(authority, it.id, it.prior, it.priorDates?.takeIf { d -> d != it.dates }) }
}
/** Walks [parentId]'s direct sub-task rows, overrides left out, with [read] on each. */
private fun <T : Any> childRows(authority: String, parentId: Long, projection: Array<String>?, read: (ColumnReader) -> T?): List<T> =
resolver.query(
TasksContract.tasksUri(authority),
projection,
"${Tasks.PARENT_ID} = ? AND (${Tasks.DELETED} IS NULL OR ${Tasks.DELETED} = 0)",
arrayOf(parentId.toString()),
null,
)?.use { c ->
val r = CursorColumnReader(c)
buildList {
while (c.moveToNext()) {
if (r.getLong(Tasks.ORIGINAL_INSTANCE_ID) != null || r.getLong(Tasks.ID) == parentId) continue
read(r)?.let(::add)
}
}
} ?: emptyList()
/**
* Undoes the tick that left [taskId]'s occurrence at [occurrenceStart] — or,
* with no occurrence, the tick that left the copy [taskId]: the series goes
* back where it was and the copy goes, as long as the series is still where
* the tick moved it and the copy has nothing filed under it. False when it
* cannot, so the caller reopens the copy as a task of its own.
* the tick moved it and the copy has nothing filed under it. The sub-tasks
* the tick reset get their state back from [resets]. False when it cannot,
* so the caller reopens the copy as a task of its own.
*/
private fun movedBack(taskId: Long, occurrenceStart: Instant?): Boolean = runCatching {
val authority = authority()
@@ -786,21 +901,29 @@ class AndroidTasksDataSource @Inject constructor(
?: 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 after = tick.after ?: return false
// Compared on what the tick recorded: one from before IS_ALLDAY and TZ were read has neither.
val now = seriesState(authority, series) ?: return false
if (after.any { (column, value) -> now[column] != value } || subtaskIds(tick.copyId).isNotEmpty()) return false
val restore = before.filter { (column, value) -> after[column] != value }
val ops = arrayListOf(
ContentProviderOperation.newUpdate(taskUri(authority, series)).withValues(restore.toContentValues()).build(),
ContentProviderOperation.newDelete(taskUri(authority, tick.copyId)).build(),
)
val anchor = SubtaskReset.anchor(after)
anchor?.let { at ->
runCatching { ops += subtaskRestore(authority, resets.get(authority, series, at)) }
.onFailure { Log.w(TAG, "Reading the reset sub-tasks failed", it) }
}
resolver.applyBatch(authority, ops)
ticks.remove(key)
anchor?.let { resets.remove(authority, series, it) }
true
}.onFailure { Log.w(TAG, "Moving the series back failed", it) }.getOrDefault(false)
/** The columns a tick moves a series by, or null when [taskId] is no series. */
private fun seriesState(authority: String, taskId: Long): Map<String, Any?>? {
if (seriesRules(listOf(taskId)).isEmpty()) return null
return resolver.query(taskUri(authority, taskId), SERIES_STATE, null, null, null)?.use { c ->
private fun seriesState(authority: String, taskId: Long): Map<String, Any?>? =
resolver.query(taskUri(authority, taskId), SERIES_STATE, null, null, null)?.use { c ->
if (!c.moveToFirst()) return@use null
SERIES_STATE.indices.associate { i ->
SERIES_STATE[i] to when (c.getType(i)) {
@@ -809,8 +932,7 @@ class AndroidTasksDataSource @Inject constructor(
else -> c.getString(i)
}
}
}
}
}?.takeIf { state -> listOf(Tasks.RRULE, Tasks.RDATE).any { !(state[it] as? String).isNullOrBlank() } }
private fun lastTaskId(authority: String): Long =
resolver.query(TasksContract.tasksUri(authority), arrayOf(Tasks.ID), null, null, "${Tasks.ID} DESC")
@@ -830,7 +952,12 @@ class AndroidTasksDataSource @Inject constructor(
}
}?.singleOrNull()
private val ticks = TickedCopies { context.getSharedPreferences(TICKED_COPIES, Context.MODE_PRIVATE) }
private val ticks = TickedCopies(
prefs = { context.getSharedPreferences(TICKED_COPIES, Context.MODE_PRIVATE) },
known = { context.getSharedPreferences(KNOWN_COPIES, Context.MODE_PRIVATE) },
)
private val resets = TickResets { context.getSharedPreferences(TICK_RESETS, Context.MODE_PRIVATE) }
/** The copy a tick left for [taskId]'s occurrence at [occurrenceStart], if one was remembered. */
private fun tickedCopy(taskId: Long, occurrenceStart: Instant): Long? =
@@ -939,10 +1066,22 @@ 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. */
/** Every copy a tick on this install left, kept past [TickedCopies.HORIZON_MS] alongside the marker on the row. */
private const val KNOWN_COPIES = "provider_known_copies"
/** The sub-tasks each tick reset, kept apart from its copy. */
private const val TICK_RESETS = "provider_tick_resets"
/** What resetting a sub-task reads: whether it recurs, its progress and its dates. */
private val SUBTASK_STATE = arrayOf(
Tasks.ID, Tasks.ORIGINAL_INSTANCE_ID, Tasks.RRULE, Tasks.RDATE, Tasks.STATUS, Tasks.PERCENT_COMPLETE, Tasks.COMPLETED,
Tasks.DTSTART, Tasks.DUE, Tasks.IS_ALLDAY, Tasks.TZ, Tasks.UID,
)
/** What a tick changes on a series, and an undo changes back; with what its rule is read in. */
private val SERIES_STATE = arrayOf(
Tasks.DTSTART, Tasks.DUE, Tasks.DURATION, Tasks.RRULE, Tasks.RDATE, Tasks.EXDATE,
Tasks.STATUS, Tasks.COMPLETED, Tasks.PERCENT_COMPLETE,
Tasks.STATUS, Tasks.COMPLETED, Tasks.PERCENT_COMPLETE, Tasks.IS_ALLDAY, Tasks.TZ,
)
private const val TAG = "AndroidTasksDataSource"
@@ -950,5 +1089,3 @@ 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,23 @@
package de.jeanlucmakiola.agendula.data.tasks
/** SQLite's default bound-argument limit on older Android releases. */
internal const val MAX_SQL_ARGS = 999
/**
* `idColumn IN (…)` selections over [ids], each with its arguments and
* [filterArgs] after them, in chunks that keep every query within [limit]
* bound arguments.
*/
internal fun inSelections(
idColumn: String,
ids: Collection<Long>,
filter: String? = null,
filterArgs: List<String> = emptyList(),
limit: Int = MAX_SQL_ARGS,
): List<Pair<String, Array<String>>> = ids.distinct().chunked(limit - filterArgs.size).map { chunk ->
val selection = buildString {
append("$idColumn IN (${chunk.joinToString(",") { "?" }})")
if (filter != null) append(" AND ($filter)")
}
selection to (chunk.map(Long::toString) + filterArgs).toTypedArray()
}
@@ -0,0 +1,266 @@
package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.isClosed
import de.jeanlucmakiola.agendula.domain.recurrence.ExpansionWindow
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
import de.jeanlucmakiola.agendula.domain.recurrence.dateZone
import de.jeanlucmakiola.agendula.domain.statusFromInt
import java.time.Duration
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.temporal.ChronoUnit
import kotlin.time.Duration.Companion.days
import kotlin.time.Instant
private const val MILLIS_PER_SECOND = 1000L
private const val MILLIS_PER_DAY = 24L * 60 * 60 * 1000
/** A sub-task's progress, which a routine's next occurrence starts without. */
internal data class SubtaskState(val status: TaskStatus, val percent: Int?, val completedAt: Long?) {
/** Open with nothing done yet: what a reset leaves. */
val isFresh: Boolean get() = status == TaskStatus.NEEDS_ACTION && (percent ?: 0) == 0 && completedAt == null
companion object {
val FRESH = SubtaskState(TaskStatus.NEEDS_ACTION, null, null)
}
}
/** A sub-task's own start and due, as epoch millis. */
internal data class SubtaskDates(val start: Long?, val due: Long?) {
val isEmpty: Boolean get() = start == null && due == null
/** The same dates as a store keeps them: to the second, or for an all-day task to the day. */
fun sameAs(other: SubtaskDates, isAllDay: Boolean): Boolean {
fun unit(at: Long?) = at?.let { Math.floorDiv(it, if (isAllDay) MILLIS_PER_DAY else MILLIS_PER_SECOND) }
return unit(start) == unit(other.start) && unit(due) == unit(other.due)
}
companion object {
val NONE = SubtaskDates(null, null)
}
}
/**
* A sub-task as [SubtaskReset] sees it; [recurring] also covers a copy a tick of
* a recurring one left. Its dates are read in [dateZone] of [isAllDay] and [timeZone].
*/
internal class SubtaskNode(
val id: Long,
val recurring: Boolean,
val state: SubtaskState,
val dates: SubtaskDates = SubtaskDates.NONE,
val isAllDay: Boolean = false,
val timeZone: String? = null,
)
/**
* A sub-task [SubtaskReset] reset: its state and dates before, and the dates the
* reset gave it. A record from before dates were kept has neither, and its dates
* are neither compared nor restored.
*/
internal data class ResetSubtask(
val id: Long,
val prior: SubtaskState,
val priorDates: SubtaskDates? = SubtaskDates.NONE,
val dates: SubtaskDates? = priorDates,
)
/** A reset sub-task as it is now, for [SubtaskReset.toRestore]. */
internal class SubtaskNow(val state: SubtaskState, val dates: SubtaskDates, val isAllDay: Boolean)
/** What moving a series back does with its reset sub-tasks: who gets their state back, and who changed since. */
internal class SubtaskRestore(val restore: List<ResetSubtask>, val changed: List<Long>)
/**
* How far a series moved in [seriesZone]: wall-clock time for a timed sub-task,
* so it keeps its place relative to the routine across a DST change, and the
* change of calendar date for an all-day one. [floating] reads a sub-task with
* no zone of its own.
*/
internal class SeriesShift(from: Long, to: Long, seriesZone: ZoneId, private val floating: ZoneId = ZoneId.systemDefault()) {
private val delta = Duration.between(local(from, seriesZone), local(to, seriesZone))
private val days = ChronoUnit.DAYS.between(local(from, seriesZone).toLocalDate(), local(to, seriesZone).toLocalDate())
/** [at] moved along. */
fun apply(at: Long, isAllDay: Boolean, timeZone: String? = null): Long {
val zone = dateZone(isAllDay, timeZone, floating)
val moved = if (isAllDay) local(at, zone).plusDays(days) else local(at, zone).plus(delta)
return moved.atZone(zone).toInstant().toEpochMilli()
}
private fun local(at: Long, zone: ZoneId) = LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(at), zone)
}
/**
* What a routine's sub-tasks do when its series moves on to the next occurrence
* (#160): the ones completed or in progress start over, as in tasks.org, dated
* ones move along with the routine, and moving the series back gives them their
* state and dates again.
*/
internal object SubtaskReset {
/** Only bounds the search for a next occurrence. */
private val LOOKAHEAD = (366 * 1000).days
/**
* The `X-` property Agendula puts on the copy a provider tick of a recurring
* sub-task left, valued with the UID of the parent it sits under, so any
* device knows it for one while it stays there.
*/
const val COPY_MARKER = "X-AGENDULA-TICK-COPY"
/** The provider `properties` mimetype DAVx5 keeps unknown properties under, and round-trips. */
const val UNKNOWN_PROPERTY_MIMETYPE = "vnd.android.cursor.item/vnd.ical4android.unknown-property"
private val MARKER_JSON = Regex("""^\s*\[\s*"$COPY_MARKER"\s*,\s*"((?:[^"\\]|\\.)*)"""", RegexOption.IGNORE_CASE)
/** The marker for a copy under the parent [parentUid], as DAVx5 stores an unknown property: a JSON array of name and value. */
fun copyMarkerJson(parentUid: String): String =
"[\"$COPY_MARKER\",\"${parentUid.replace("\\", "\\\\").replace("\"", "\\\"")}\"]"
/** The parent UID an unknown-property row's JSON marks its task a copy under, or null when it is no marker. */
fun markerParentFromJson(json: String?): String? =
json?.let(MARKER_JSON::find)?.groupValues?.get(1)?.replace(Regex("""\\(.)"""), "$1")
/** The parent UID a stored residue's marker names, as a copy synced in from a provider install carries it. */
fun markerParentFromResidue(residue: String?): String? = residue?.lines()?.firstNotNullOfOrNull {
val line = it.trim()
val next = line.getOrNull(COPY_MARKER.length)
if (!line.startsWith(COPY_MARKER, ignoreCase = true) || (next != ':' && next != ';')) return@firstNotNullOfOrNull null
line.substringAfter(':').replace(Regex("""\\([\\;,])"""), "$1")
}
/** Whether a marker naming [markerParent] makes a task under [parentUid] a copy: only under the parent it came from. */
fun isCopyOf(markerParent: String?, parentUid: String?): Boolean = markerParent != null && markerParent == parentUid
/**
* The sub-tasks under [parentId], all the way down, to reset: started ones,
* and with a [shift], dated ones too. A recurring one has a series of its
* own and is left alone with everything under it; a cancelled one stays as
* it is.
*/
fun toReset(parentId: Long, shift: SeriesShift? = null, children: (Long) -> List<SubtaskNode>): List<ResetSubtask> {
val seen = mutableSetOf(parentId)
val reset = mutableListOf<ResetSubtask>()
fun walk(id: Long) {
children(id).filter { !it.recurring && seen.add(it.id) }.forEach {
val moves = shift != null && !it.dates.isEmpty
if (it.state.status != TaskStatus.CANCELLED && (!it.state.isFresh || moves)) {
val dates = if (shift == null) it.dates else SubtaskDates(
it.dates.start?.let { at -> shift.apply(at, it.isAllDay, it.timeZone) },
it.dates.due?.let { at -> shift.apply(at, it.isAllDay, it.timeZone) },
)
reset += ResetSubtask(it.id, it.state, it.dates, dates)
}
walk(it.id)
}
}
walk(parentId)
return reset
}
/**
* A provider row as a node. Nothing the provider sets on a detached copy marks
* it as one, so a copy is known by the [copies] ticks on this install left, or
* by a [markerParent] naming the [parentUid] it sits under: it belongs to a
* recurring sub-task and counts as recurring.
*/
fun providerNode(
id: Long,
rrule: String?,
rdate: String?,
copies: Set<Long>,
markerParent: String? = null,
parentUid: String? = null,
state: SubtaskState,
dates: SubtaskDates = SubtaskDates.NONE,
isAllDay: Boolean = false,
timeZone: String? = null,
) = SubtaskNode(
id,
!rrule.isNullOrBlank() || !rdate.isNullOrBlank() || id in copies || isCopyOf(markerParent, parentUid),
state, dates, isAllDay, timeZone,
)
/**
* The reset sub-tasks still as the reset left them, so moving back may
* restore them, and the ones changed since, which keep the user's state.
* A deleted one is neither.
*/
fun toRestore(reset: List<ResetSubtask>, current: (Long) -> SubtaskNow?): SubtaskRestore {
val restore = mutableListOf<ResetSubtask>()
val changed = mutableListOf<Long>()
reset.forEach { entry ->
val now = current(entry.id) ?: return@forEach
val unchanged = now.state.isFresh && (entry.dates == null || now.dates.sameAs(entry.dates, now.isAllDay))
if (unchanged) restore += entry else changed += entry.id
}
return SubtaskRestore(restore, changed)
}
fun encode(reset: List<ResetSubtask>): String = reset.joinToString(";") {
listOf(
it.id, it.prior.status.name, it.prior.percent, it.prior.completedAt,
it.priorDates?.start, it.priorDates?.due, it.dates?.start, it.dates?.due,
).joinToString(",") { value -> value?.toString().orEmpty() }
}
fun decode(raw: String?): List<ResetSubtask> = raw.orEmpty().split(';').mapNotNull { entry ->
val parts = entry.split(',')
if (parts.size != 4 && parts.size != 8) return@mapNotNull null
val id = parts[0].toLongOrNull() ?: return@mapNotNull null
val status = TaskStatus.entries.firstOrNull { it.name == parts[1] } ?: return@mapNotNull null
val state = SubtaskState(status, parts[2].toIntOrNull(), parts[3].toLongOrNull())
if (parts.size == 4) return@mapNotNull ResetSubtask(id, state, priorDates = null, dates = null)
ResetSubtask(
id, state,
SubtaskDates(parts[4].toLongOrNull(), parts[5].toLongOrNull()),
SubtaskDates(parts[6].toLongOrNull(), parts[7].toLongOrNull()),
)
}
/** A provider series' rule set from its columns. */
fun specOf(state: Map<String, Any?>): RecurrenceSpec? = TaskMapper.seriesSpec(MapColumnReader(state))
/** How the series moved from [before] to [after], read in the zone its rule is expanded in. */
fun shiftOf(before: Map<String, Any?>, after: Map<String, Any?>, floating: ZoneId = ZoneId.systemDefault()): SeriesShift? {
val from = anchor(before) ?: return null
val to = anchor(after) ?: return null
val reader = MapColumnReader(after)
return SeriesShift(from, to, dateZone(reader.getBoolean(Tasks.IS_ALLDAY), reader.getString(Tasks.TZ), floating), floating)
}
/**
* Whether a provider tick moved the series on, judged by its columns
* [before] and [after]: its start (or due) moved later and it is still open,
* with [next], its rule set as it now stands, generating an occurrence from
* there. An `EXDATE` or a status alone is no move; a series whose rule ran
* out is finished.
*/
fun movedOn(before: Map<String, Any?>?, after: Map<String, Any?>?, next: RecurrenceSpec? = after?.let(::specOf)): Boolean {
val was = anchor(before ?: return false) ?: return false
val now = anchor(after ?: return false) ?: return false
if (now <= was) return false
if (statusFromInt((after[Tasks.STATUS] as? Number)?.toInt()).isClosed) return false
val spec = next ?: return false
if (spec.rrule == null && spec.rdate == null) return false
val parts = spec.rrule?.split(';')?.map { it.trim() }?.filter { it.isNotEmpty() }
val count = parts?.firstOrNull { it.startsWith("COUNT=", ignoreCase = true) }?.substringAfter('=')?.toIntOrNull()
if (count != null && count <= 0) return false
// Expanded from the old start, as an expansion always yields its own start
// even past UNTIL. COUNT is left out: it was spent before the new start.
val probe = spec.copy(
rrule = parts?.filterNot { it.startsWith("COUNT=", ignoreCase = true) }?.joinToString(";"),
anchor = Instant.fromEpochMilliseconds(was),
)
val from = Instant.fromEpochMilliseconds(now)
return RecurrenceExpander.expand(probe, ExpansionWindow(from = from, until = from + LOOKAHEAD, maxOccurrences = 1)).isNotEmpty()
}
/** The anchor a series' columns put it at: its start, or else its due date. */
fun anchor(state: Map<String, Any?>): Long? =
(state[Tasks.DTSTART] as? Number)?.toLong() ?: (state[Tasks.DUE] as? Number)?.toLong()
}
@@ -8,6 +8,7 @@ import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.export.ExportTask
import de.jeanlucmakiola.agendula.domain.priorityFromICal
import de.jeanlucmakiola.agendula.domain.statusFromInt
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
import kotlin.time.Instant
/** Maps a provider row (via [ColumnReader]) to a domain model. Pure + testable. */
@@ -77,6 +78,20 @@ object TaskMapper {
return if (rrule == null && rdate == null) null else SeriesRule(rrule, rdate)
}
/** The rule set of a `tasks` row, anchored on its start or else its due date; null without either date. */
fun seriesSpec(r: ColumnReader): RecurrenceSpec? {
val rule = seriesRule(r)
val anchor = r.getLong(Tasks.DTSTART) ?: r.getLong(Tasks.DUE) ?: return null
return RecurrenceSpec(
rrule = rule?.rrule,
rdate = rule?.rdate,
exdate = r.getString(Tasks.EXDATE),
anchor = Instant.fromEpochMilliseconds(anchor),
isAllDay = r.getBoolean(Tasks.IS_ALLDAY),
timeZone = r.getString(Tasks.TZ),
)
}
/**
* The occurrence's `RECURRENCE-ID` anchor.
*
@@ -0,0 +1,42 @@
package de.jeanlucmakiola.agendula.data.tasks
import android.content.SharedPreferences
/**
* The sub-tasks a provider tick reset, as [SubtaskReset.encode] writes them,
* kept for [TickedCopies.HORIZON_MS] under the series and the anchor the tick
* moved it to: whether or not the provider left a copy that [TickedCopies]
* could record, moving the series back from there finds them.
*/
internal class TickResets(private val prefs: () -> SharedPreferences) {
fun put(authority: String, seriesId: Long, anchor: Long, reset: String, at: Long) {
val stale = expire(prefs().all, at)
prefs().edit().apply {
stale.forEach(::remove)
putString(key(authority, seriesId, anchor), encode(at, reset))
}.apply()
}
fun get(authority: String, seriesId: Long, anchor: Long): String? =
prefs().getString(key(authority, seriesId, anchor), null)?.let(::decode)
fun remove(authority: String, seriesId: Long, anchor: Long) {
prefs().edit().remove(key(authority, seriesId, anchor)).apply()
}
companion object {
fun key(authority: String, seriesId: Long, anchor: Long) = "$authority|$seriesId|$anchor"
fun encode(at: Long, reset: String) = "$at|$reset"
/** The reset an entry holds, or null when it is not one. */
fun decode(raw: String): String? = raw.substringBefore('|', "").toLongOrNull()?.let { raw.substringAfter('|') }
/** The keys of [entries] past the horizon at [now], unreadable ones included. */
fun expire(entries: Map<String, *>, now: Long): Set<String> = entries.filter { (_, value) ->
val at = (value as? String)?.substringBefore('|', "")?.toLongOrNull() ?: 0L
now - at > TickedCopies.HORIZON_MS
}.keys
}
}
@@ -7,6 +7,7 @@ import kotlin.time.Instant
/**
* What ticking a provider occurrence left: the copy the provider detached, and
* the series' columns before and after the tick, so an undo can move it back.
* The sub-tasks it reset are kept apart, in [TickResets].
*/
internal class Tick(
val copyId: Long,
@@ -16,8 +17,12 @@ internal class Tick(
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) {
/**
* The provider keeps no record of its ticks, so they are kept here for
* [HORIZON_MS]. The copies' ids are kept for good in [known]: a detached copy
* carries nothing that marks it as one.
*/
internal class TickedCopies(private val prefs: () -> SharedPreferences, private val known: () -> SharedPreferences) {
fun key(authority: String, taskId: Long, occurrenceStart: Instant) =
"$authority|$taskId|${wholeSecond(occurrenceStart).epochSeconds}"
@@ -31,21 +36,40 @@ internal class TickedCopies(private val prefs: () -> SharedPreferences) {
(value as? String)?.let(::decode)?.takeIf { it.copyId == copyId }?.let { key to it }
}
/** Every copy a tick in Agendula left in [authority]. */
fun copies(authority: String): Set<Long> {
val kept = knownIds(authority).mapNotNullTo(HashSet()) { it.toLongOrNull() }
prefs().all.forEach { (key, value) ->
if (key.startsWith("$authority|")) (value as? String)?.let(::decode)?.let { kept += it.copyId }
}
return kept
}
/** Records [tick] under [key]; ticks past [HORIZON_MS] go, their copies stay known. */
fun put(key: String, tick: Tick) {
val stale = prefs().all.filter { (_, value) ->
val at = (value as? String)?.let(::decode)?.at ?: 0L
tick.at - at > HORIZON_MS
}.keys
val (stale, keep) = expire(prefs().all.mapValues { (_, value) -> (value as? String)?.let(::decode) }, tick.at)
val known = keep + (key.substringBefore('|') to (keep[key.substringBefore('|')].orEmpty() + tick.copyId))
known().edit().apply {
known.forEach { (authority, ids) -> putStringSet(authority, knownIds(authority) + ids.map(Long::toString)) }
}.apply()
prefs().edit().apply {
stale.forEach(::remove)
putString(key, encode(tick))
}.apply()
}
/** Drops the tick under [key], and its copy from the known ones: moving back deleted it, and its id may be reused. */
fun remove(key: String) {
val copy = get(key)?.copyId
prefs().edit().remove(key).apply()
if (copy != null) {
val authority = key.substringBefore('|')
known().edit().putStringSet(authority, knownIds(authority) - copy.toString()).apply()
}
}
private fun knownIds(authority: String): Set<String> = known().getStringSet(authority, emptySet()).orEmpty().toSet()
private fun encode(tick: Tick): String = JSONObject().apply {
put("copy", tick.copyId)
put("at", tick.at)
@@ -80,5 +104,16 @@ internal class TickedCopies(private val prefs: () -> SharedPreferences) {
companion object {
/** As long as the own store keeps a tick undoable. */
const val HORIZON_MS = 30L * 24 * 60 * 60 * 1000
/**
* The keys of [ticks] past the horizon at [now], unreadable ones included,
* and per authority the copies they leave, which stay known after the tick goes.
*/
fun expire(ticks: Map<String, Tick?>, now: Long): Pair<Set<String>, Map<String, Set<Long>>> {
val stale = ticks.filter { (_, tick) -> now - (tick?.at ?: 0L) > HORIZON_MS }
val keep = stale.entries.mapNotNull { (key, tick) -> tick?.let { key.substringBefore('|') to it.copyId } }
.groupBy({ it.first }, { it.second }).mapValues { it.value.toSet() }
return stale.keys to keep
}
}
}
@@ -51,6 +51,8 @@ data class AdvancedOccurrenceEntity(
@ColumnInfo(name = "undoable", defaultValue = "1") val undoable: Boolean = true,
/** Set once the moved series reached the server, which then holds the move itself. */
@ColumnInfo(name = "settled", defaultValue = "0") val settled: Boolean = false,
/** The series' sub-tasks the move reset, with their state before, as [de.jeanlucmakiola.agendula.data.tasks.SubtaskReset.encode] writes them. */
@ColumnInfo(name = "reset_subtasks", defaultValue = "") val resetSubtasks: String = "",
) {
val dropped: List<Instant>
get() = droppedExdates.split(',').mapNotNull { it.toLongOrNull()?.let(Instant::fromEpochMilliseconds) }
@@ -1,6 +1,14 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import android.util.Log
import androidx.room.InvalidationTracker
import de.jeanlucmakiola.agendula.data.tasks.ResetSubtask
import de.jeanlucmakiola.agendula.data.tasks.SeriesShift
import de.jeanlucmakiola.agendula.data.tasks.SubtaskDates
import de.jeanlucmakiola.agendula.data.tasks.SubtaskNode
import de.jeanlucmakiola.agendula.data.tasks.SubtaskNow
import de.jeanlucmakiola.agendula.data.tasks.SubtaskReset
import de.jeanlucmakiola.agendula.data.tasks.SubtaskState
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.domain.TaskReminderOrder
@@ -18,6 +26,7 @@ import de.jeanlucmakiola.agendula.domain.recurrence.ExpansionWindow
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
import java.time.ZoneId
import de.jeanlucmakiola.agendula.domain.recurrence.dateZone
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@@ -91,6 +100,8 @@ private val TASK_TABLES = arrayOf("tasks", "task_lists", "task_alarms")
/** Distinct alarm texts whose `ACKNOWLEDGED` is remembered between scheduler passes. */
private const val ACK_CACHE_SIZE = 512
private const val TAG = "RoomTasksDataSource"
/**
* [TasksDataSource] over Agendula's own Room store.
*
@@ -638,6 +649,7 @@ class RoomTasksDataSource @Inject constructor(
isDirty = true,
),
)
val (reset, resetLists) = resetSubtasks(master, newAnchor, now)
val copyId = if (override != null) {
detach(override, now, close)
} else {
@@ -663,13 +675,66 @@ class RoomTasksDataSource @Inject constructor(
ruleAfter = ruleAfter,
droppedExdates = AdvancedOccurrenceEntity.encode(TaskFormWriter.instantsBefore(master.exdate, newAnchor, zone)),
fromOverride = override != null,
resetSubtasks = SubtaskReset.encode(reset),
),
)
advanced.insertDetached(detached.map { (at, id) -> AdvancedDetachedEntity(copyId = copyId, anchor = at, taskId = id) })
writes.onWritten(setOf(master.listId))
writes.onWritten(setOf(master.listId) + resetLists)
return true
}
/**
* Resets what [SubtaskReset] picks under [master] for the series' next
* occurrence at [newAnchor], dated ones moved along; the lists it wrote to.
* A copy a provider tick of a recurring sub-task left, synced in, is known by
* its marker while it stays under the parent the marker names.
*/
private fun resetSubtasks(master: TaskEntity, newAnchor: Instant, now: Instant): Pair<List<ResetSubtask>, Set<Long>> {
val floating = ZoneId.of(zone())
val shift = master.recurrenceAnchor?.let {
SeriesShift(it.toEpochMilliseconds(), newAnchor.toEpochMilliseconds(), dateZone(master.isAllDay, master.timezone, floating), floating)
}
val loaded = HashMap<Long, TaskEntity>().apply { put(master.id, master) }
val reset = SubtaskReset.toReset(master.id, shift) { id ->
val parentUid = loaded[id]?.uid
tasks.subtasks(id).map { row ->
val sub = row.task.also { loaded[it.id] = it }
val copy = SubtaskReset.isCopyOf(SubtaskReset.markerParentFromResidue(sub.unknownProperties), parentUid)
SubtaskNode(sub.id, sub.isRecurring || copy, sub.progress, sub.dates, sub.isAllDay, sub.timezone)
}
}
val lists = reset.mapNotNull { entry ->
loaded[entry.id]?.also { tasks.update(it.withProgress(SubtaskState.FRESH, entry.dates, now)) }?.listId
}
return reset to lists.toSet()
}
/** What [resetSubtasks] reset for [record], back as it was where nothing changed it since. */
private fun restoreSubtasks(record: AdvancedOccurrenceEntity, now: Instant): Set<Long> {
val entries = SubtaskReset.decode(record.resetSubtasks)
val current = entries.mapNotNull { entry -> tasks.entity(entry.id)?.takeUnless { it.isDeleted } }.associateBy { it.id }
val outcome = SubtaskReset.toRestore(entries) { id -> current[id]?.let { SubtaskNow(it.progress, it.dates, it.isAllDay) } }
if (outcome.changed.isNotEmpty()) Log.i(TAG, "Sub-tasks ${outcome.changed} changed since the reset and keep their state")
return outcome.restore.mapNotNull { entry ->
current[entry.id]?.also { tasks.update(it.withProgress(entry.prior, entry.priorDates, now)) }?.listId
}.toSet()
}
private val TaskEntity.progress get() = SubtaskState(status, percentComplete, completedAt?.toEpochMilliseconds())
private val TaskEntity.dates get() = SubtaskDates(dtstart?.toEpochMilliseconds(), due?.toEpochMilliseconds())
/** [state] onto the row, and [dates] unless they are unknown. */
private fun TaskEntity.withProgress(state: SubtaskState, dates: SubtaskDates?, now: Instant) = copy(
status = state.status,
percentComplete = state.percent,
completedAt = state.completedAt?.let(Instant::fromEpochMilliseconds),
dtstart = if (dates == null) dtstart else dates.start?.let(Instant::fromEpochMilliseconds),
due = if (dates == null) due else dates.due?.let(Instant::fromEpochMilliseconds),
lastModified = now,
isDirty = true,
)
/** Occurrences `COUNT` has spent before [cut]: counted before `EXDATE` (RFC 5545 §3.8.5.3). */
private fun spentBefore(spec: RecurrenceSpec, cut: Instant): Int = RecurrenceExpander.expand(
spec.copy(exdate = null),
@@ -754,7 +819,7 @@ class RoomTasksDataSource @Inject constructor(
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))
writes.onWritten(setOf(master.listId) + restoreSubtasks(record, now))
return true
}
@@ -24,7 +24,7 @@ import kotlin.time.Instant
AdvancedOccurrenceEntity::class,
AdvancedDetachedEntity::class,
],
version = 5,
version = 6,
exportSchema = true,
)
@TypeConverters(Converters::class)
@@ -204,5 +204,12 @@ abstract class TasksDatabase : RoomDatabase() {
)
}
}
/** v6 remembers which sub-tasks a moved series reset, so moving it back restores them (#160). */
val MIGRATION_5_6 = object : Migration(5, 6) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE advanced_occurrences ADD COLUMN `reset_subtasks` TEXT NOT NULL DEFAULT ''")
}
}
}
}
@@ -12,6 +12,16 @@ import kotlin.time.Instant
private const val MILLIS_PER_SECOND = 1000L
private const val MILLIS_PER_DAY = 24L * 60 * 60 * 1000
/**
* The zone a task's dates are read in: UTC for an all-day one, whose dates are
* midnights there; else its [timeZone], or [floating] when it has none or an
* unknown one.
*/
fun dateZone(isAllDay: Boolean, timeZone: String?, floating: ZoneId = ZoneId.systemDefault()): ZoneId {
if (isAllDay) return ZoneId.of("UTC")
return timeZone?.let { runCatching { ZoneId.of(it) }.getOrNull() } ?: floating
}
/** The rule set of one task series, as stored. All strings are raw iCalendar values. */
data class RecurrenceSpec(
val rrule: String?,
@@ -133,11 +143,8 @@ object RecurrenceExpander {
return occurrences.indices.map { it - current }
}
private fun zoneOf(spec: RecurrenceSpec, floatingZone: ZoneId): TimeZone {
if (spec.isAllDay) return TimeZone.getTimeZone(ZoneId.of("UTC"))
val stored = spec.timeZone?.let { runCatching { ZoneId.of(it) }.getOrNull() }
return TimeZone.getTimeZone(stored ?: floatingZone)
}
private fun zoneOf(spec: RecurrenceSpec, floatingZone: ZoneId): TimeZone =
TimeZone.getTimeZone(dateZone(spec.isAllDay, spec.timeZone, floatingZone))
/**
* All-day series are date-anchored: pin the anchor to UTC midnight, as it is
@@ -0,0 +1,24 @@
package de.jeanlucmakiola.agendula.data.tasks
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class SqlChunksTest {
@Test
fun `ids are split so no query binds more than the limit`() {
val ids = (1L..2_100L).toList()
val chunks = inSelections("_id", ids, filter = "deleted = ?", filterArgs = listOf("0"))
assertThat(chunks.map { it.second.size }).containsExactly(999, 999, 105).inOrder()
assertThat(chunks.flatMap { (_, args) -> args.dropLast(1) }).isEqualTo(ids.map(Long::toString))
assertThat(chunks.all { (_, args) -> args.last() == "0" }).isTrue()
assertThat(chunks.first().first).endsWith("?) AND (deleted = ?)")
}
@Test
fun `repeated ids are bound once, and none give no query`() {
assertThat(inSelections("_id", listOf(3L, 3L, 4L)).single().second).asList().containsExactly("3", "4")
assertThat(inSelections("_id", emptyList())).isEmpty()
}
}
@@ -0,0 +1,299 @@
package de.jeanlucmakiola.agendula.data.tasks
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
import org.junit.jupiter.api.Test
import java.time.ZoneId
import java.time.ZoneOffset
import kotlin.time.Instant
class SubtaskResetTest {
private val done = SubtaskState(TaskStatus.COMPLETED, 100, 1_000L)
private val halfway = SubtaskState(TaskStatus.IN_PROCESS, 50, null)
private val cancelled = SubtaskState(TaskStatus.CANCELLED, null, null)
private fun node(id: Long, state: SubtaskState = done, recurring: Boolean = false) = SubtaskNode(id, recurring, state)
private fun reset(vararg tree: Pair<Long, List<SubtaskNode>>): List<Long> {
val children = tree.toMap()
return SubtaskReset.toReset(1L) { children[it].orEmpty() }.map { it.id }
}
@Test
fun `completed and half-done sub-tasks of a routine start over, fresh ones are left (#160)`() {
val reopened = reset(1L to listOf(node(2), node(3, SubtaskState.FRESH), node(4, halfway)))
assertThat(reopened).containsExactly(2L, 4L)
}
@Test
fun `an open sub-task with progress counts as started`() {
assertThat(reset(1L to listOf(node(2, SubtaskState(TaskStatus.NEEDS_ACTION, 20, null))))).containsExactly(2L)
}
@Test
fun `a cancelled sub-task stays cancelled, what is under it still starts over`() {
assertThat(reset(1L to listOf(node(2, cancelled)), 2L to listOf(node(3)))).containsExactly(3L)
}
@Test
fun `nested sub-tasks start over too, also under a fresh one`() {
val reopened = reset(
1L to listOf(node(2, SubtaskState.FRESH)),
2L to listOf(node(3)),
3L to listOf(node(4)),
)
assertThat(reopened).containsExactly(3L, 4L)
}
@Test
fun `a recurring sub-task, or a copy a tick of one left, is left alone with what is under it`() {
val reopened = reset(
1L to listOf(node(2, recurring = true), node(3), node(5, recurring = true)),
2L to listOf(node(4)),
)
assertThat(reopened).containsExactly(3L)
}
@Test
fun `a provider copy a tick in Agendula left is left alone, a plain sub-task is not`() {
val copies = setOf(3L)
val rows = listOf(
SubtaskReset.providerNode(2, rrule = "FREQ=DAILY", rdate = null, copies = copies, state = done),
SubtaskReset.providerNode(3, rrule = null, rdate = null, copies = copies, state = done),
SubtaskReset.providerNode(4, rrule = null, rdate = "20261110T080000Z", copies = copies, state = done),
SubtaskReset.providerNode(5, rrule = " ", rdate = null, copies = copies, state = done),
)
assertThat(SubtaskReset.toReset(1L) { if (it == 1L) rows else emptyList() }.map { it.id }).containsExactly(5L)
}
@Test
fun `a cycle or the parent listed under itself is not walked twice`() {
val reopened = reset(
1L to listOf(node(1), node(2)),
2L to listOf(node(3)),
3L to listOf(node(2)),
)
assertThat(reopened).containsExactly(2L, 3L)
}
@Test
fun `what was reset survives encoding`() {
val reset = listOf(
ResetSubtask(2, done),
ResetSubtask(3, halfway, SubtaskDates(1L, 2L), SubtaskDates(11L, 12L)),
ResetSubtask(4, SubtaskState(TaskStatus.NEEDS_ACTION, 10, null), SubtaskDates(null, 5L), SubtaskDates(null, 15L)),
)
assertThat(SubtaskReset.decode(SubtaskReset.encode(reset))).isEqualTo(reset)
assertThat(SubtaskReset.decode("")).isEmpty()
assertThat(SubtaskReset.decode(null)).isEmpty()
assertThat(SubtaskReset.decode("x,COMPLETED,,;2,NOPE,,;3,IN_PROCESS")).isEmpty()
}
@Test
fun `a record from before dates were kept restores the state and leaves the dates`() {
val legacy = SubtaskReset.decode("2,COMPLETED,100,1000").single()
assertThat(legacy).isEqualTo(ResetSubtask(2, done, priorDates = null, dates = null))
val dated = SubtaskNow(SubtaskState.FRESH, SubtaskDates(at("2026-11-09T10:00:00Z"), null), isAllDay = false)
assertThat(SubtaskReset.toRestore(listOf(legacy)) { dated }.restore).containsExactly(legacy)
assertThat(SubtaskReset.encode(listOf(legacy))).isEqualTo("2,COMPLETED,100,1000,,,,")
}
private fun now(state: SubtaskState, dates: SubtaskDates = SubtaskDates.NONE, allDay: Boolean = false) = SubtaskNow(state, dates, allDay)
@Test
fun `moving back restores only the sub-tasks still as the reset left them`() {
val moved = SubtaskDates(20_000L, null)
val reset = listOf(
ResetSubtask(2, done),
ResetSubtask(3, halfway),
ResetSubtask(4, done),
ResetSubtask(5, done, SubtaskDates(10_000L, null), moved),
ResetSubtask(6, done, SubtaskDates(10_000L, null), moved),
)
val current = mapOf(
2L to now(SubtaskState.FRESH),
3L to now(done),
5L to now(SubtaskState.FRESH, moved),
// Re-dated since: the user's dates win.
6L to now(SubtaskState.FRESH, SubtaskDates(30_000L, null)),
)
val outcome = SubtaskReset.toRestore(reset, current::get)
assertThat(outcome.restore.map { it.id }).containsExactly(2L, 5L)
// 4 is gone: neither restored nor reported as changed.
assertThat(outcome.changed).containsExactly(3L, 6L)
}
@Test
fun `dates compare as a store keeps them, to the second or the day`() {
val reset = ResetSubtask(2, done, SubtaskDates.NONE, SubtaskDates(at("2026-11-09T10:00:00.250Z"), null))
val allDay = ResetSubtask(3, done, SubtaskDates.NONE, SubtaskDates(null, at("2026-11-10T00:00:00Z")))
fun restores(entry: ResetSubtask, stored: Long, isAllDay: Boolean) =
SubtaskReset.toRestore(listOf(entry)) { now(SubtaskState.FRESH, SubtaskDates(if (isAllDay) null else stored, if (isAllDay) stored else null), isAllDay) }
.restore.isNotEmpty()
assertThat(restores(reset, at("2026-11-09T10:00:00Z"), isAllDay = false)).isTrue()
assertThat(restores(reset, at("2026-11-09T10:00:01Z"), isAllDay = false)).isFalse()
assertThat(restores(allDay, at("2026-11-10T12:00:00Z"), isAllDay = true)).isTrue()
assertThat(restores(allDay, at("2026-11-11T00:00:00Z"), isAllDay = true)).isFalse()
}
private val berlin = ZoneId.of("Europe/Berlin")
private fun at(text: String) = java.time.Instant.parse(text).toEpochMilli()
@Test
fun `a dated sub-task keeps its wall-clock place to the routine across a DST change`() {
// A weekly routine at 09:00 Berlin moves from the last Monday of summer time to the first of winter time.
val shift = SeriesShift(at("2026-10-19T07:00:00Z"), at("2026-10-26T08:00:00Z"), berlin, floating = ZoneOffset.UTC)
assertThat(shift.apply(at("2026-10-20T08:00:00Z"), isAllDay = false, timeZone = "Europe/Berlin")).isEqualTo(at("2026-10-27T09:00:00Z"))
assertThat(shift.apply(at("2026-10-21T00:00:00Z"), isAllDay = true)).isEqualTo(at("2026-10-28T00:00:00Z"))
}
@Test
fun `an all-day sub-task follows the routine's change of date, also on a step under a day`() {
// An eight-hourly routine moves from 20:00 to 04:00 the next day in Berlin.
val shift = SeriesShift(at("2026-11-09T19:00:00Z"), at("2026-11-10T03:00:00Z"), berlin, floating = ZoneOffset.UTC)
assertThat(shift.apply(at("2026-11-09T00:00:00Z"), isAllDay = true)).isEqualTo(at("2026-11-10T00:00:00Z"))
// From 04:00 to 12:00 the date stays.
val sameDay = SeriesShift(at("2026-11-10T03:00:00Z"), at("2026-11-10T11:00:00Z"), berlin, floating = ZoneOffset.UTC)
assertThat(sameDay.apply(at("2026-11-10T00:00:00Z"), isAllDay = true)).isEqualTo(at("2026-11-10T00:00:00Z"))
}
@Test
fun `a timed sub-task without a zone of its own is read in the floating zone`() {
val shift = SeriesShift(at("2026-10-19T07:00:00Z"), at("2026-10-26T08:00:00Z"), berlin, floating = berlin)
assertThat(shift.apply(at("2026-10-20T08:00:00Z"), isAllDay = false)).isEqualTo(at("2026-10-27T09:00:00Z"))
}
@Test
fun `moving on shifts dated sub-tasks, fresh ones included, and leaves undated fresh ones`() {
val shift = SeriesShift(at("2026-11-02T08:00:00Z"), at("2026-11-09T08:00:00Z"), ZoneOffset.UTC, floating = ZoneOffset.UTC)
val nodes = listOf(
SubtaskNode(2, false, done, SubtaskDates(at("2026-11-02T10:00:00Z"), null)),
SubtaskNode(3, false, SubtaskState.FRESH, SubtaskDates(null, at("2026-11-03T00:00:00Z")), isAllDay = true),
SubtaskNode(4, false, SubtaskState.FRESH),
SubtaskNode(5, false, cancelled, SubtaskDates(at("2026-11-02T10:00:00Z"), null)),
)
val reset = SubtaskReset.toReset(1L, shift) { if (it == 1L) nodes else emptyList() }
assertThat(reset).containsExactly(
ResetSubtask(2, done, SubtaskDates(at("2026-11-02T10:00:00Z"), null), SubtaskDates(at("2026-11-09T10:00:00Z"), null)),
ResetSubtask(3, SubtaskState.FRESH, SubtaskDates(null, at("2026-11-03T00:00:00Z")), SubtaskDates(null, at("2026-11-10T00:00:00Z"))),
)
}
@Test
fun `a provider series' shift is read in its own zone`() {
val before = mapOf<String, Any?>(Tasks.DTSTART to at("2026-10-19T07:00:00Z"), Tasks.TZ to "Europe/Berlin", Tasks.IS_ALLDAY to 0L)
val after = before + (Tasks.DTSTART to at("2026-10-26T08:00:00Z"))
val shift = SubtaskReset.shiftOf(before, after, floating = ZoneOffset.UTC)!!
assertThat(shift.apply(at("2026-10-20T08:00:00Z"), isAllDay = false, timeZone = "Europe/Berlin")).isEqualTo(at("2026-10-27T09:00:00Z"))
}
@Test
fun `a provider series' rule set is read from its columns`() {
val state = mapOf<String, Any?>(
Tasks.DTSTART to at("2026-11-02T08:00:00Z"), Tasks.RRULE to "FREQ=WEEKLY", Tasks.EXDATE to null,
Tasks.IS_ALLDAY to 1L, Tasks.TZ to null,
)
val spec = SubtaskReset.specOf(state)!!
assertThat(spec.rrule).isEqualTo("FREQ=WEEKLY")
assertThat(spec.isAllDay).isTrue()
assertThat(SubtaskReset.specOf(state + (Tasks.RRULE to " "))!!.rrule).isNull()
assertThat(SubtaskReset.specOf(state + (Tasks.DTSTART to null))).isNull()
}
@Test
fun `the copy marker names the parent it was left under`() {
val json = SubtaskReset.copyMarkerJson("parent-\"1\"")
assertThat(SubtaskReset.markerParentFromJson(json)).isEqualTo("parent-\"1\"")
assertThat(SubtaskReset.markerParentFromJson(" [\"x-agendula-tick-copy\", \"p\"]")).isEqualTo("p")
assertThat(SubtaskReset.markerParentFromJson("[\"X-AGENDULA-TICK-COPYRIGHT\",\"p\"]")).isNull()
assertThat(SubtaskReset.markerParentFromJson(null)).isNull()
assertThat(SubtaskReset.markerParentFromResidue("X-OTHER:1\r\nX-AGENDULA-TICK-COPY:p\\,1")).isEqualTo("p,1")
assertThat(SubtaskReset.markerParentFromResidue("X-AGENDULA-TICK-COPY;X-P=1:p")).isEqualTo("p")
assertThat(SubtaskReset.markerParentFromResidue("X-AGENDULA-TICK-COPYRIGHT:p")).isNull()
assertThat(SubtaskReset.markerParentFromResidue(null)).isNull()
}
@Test
fun `a marked copy counts only under the parent it came from`() {
fun node(markerParent: String?, parentUid: String?) =
SubtaskReset.providerNode(2, null, null, emptySet(), markerParent = markerParent, parentUid = parentUid, state = done)
assertThat(node("routine", "routine").recurring).isTrue()
// Moved under another parent since: an ordinary completed sub-task there.
assertThat(node("routine", "other").recurring).isFalse()
assertThat(node(null, "routine").recurring).isFalse()
assertThat(node("routine", null).recurring).isFalse()
}
@Test
fun `a moved-on provider series resets with or without a copy, and its record is found by series and anchor`() {
val before = state(start) + (Tasks.RRULE to "FREQ=WEEKLY")
val after = state(start + week) + (Tasks.RRULE to "FREQ=WEEKLY")
assertThat(SubtaskReset.movedOn(before, after)).isTrue()
assertThat(SubtaskReset.movedOn(before, before)).isFalse()
val anchor = SubtaskReset.anchor(after)!!
val key = TickResets.key("org.dmfs.tasks", 4, anchor)
val record = SubtaskReset.encode(listOf(ResetSubtask(2, done)))
assertThat(key).isEqualTo("org.dmfs.tasks|4|${start + week}")
assertThat(TickResets.decode(TickResets.encode(1L, record))).isEqualTo(record)
assertThat(SubtaskReset.toRestore(SubtaskReset.decode(record)) { now(SubtaskState.FRESH) }.restore.map { it.id }).containsExactly(2L)
}
@Test
fun `a reset record expires with the tick horizon`() {
val now = 100L * 24 * 60 * 60 * 1000
val entries = mapOf(
"a|1|1" to TickResets.encode(now - 31L * 24 * 60 * 60 * 1000, "2,COMPLETED,,,,,,"),
"a|1|2" to TickResets.encode(now - 1, "3,COMPLETED,,,,,,"),
"garbage" to "x",
)
assertThat(TickResets.expire(entries, now)).containsExactly("a|1|1", "garbage")
assertThat(TickResets.decode("x")).isNull()
}
private val week = 7L * 24 * 60 * 60 * 1000
private val start = Instant.parse("2026-11-02T08:00:00Z").toEpochMilliseconds()
private fun state(dtstart: Long?, status: Int = TasksContract.STATUS_NEEDS_ACTION, exdate: String? = null, due: Long? = null) =
mapOf<String, Any?>(Tasks.DTSTART to dtstart, Tasks.DUE to due, Tasks.STATUS to status.toLong(), Tasks.EXDATE to exdate)
private fun spec(at: Long, rrule: String = "FREQ=WEEKLY") =
RecurrenceSpec(rrule = rrule, rdate = null, exdate = null, anchor = Instant.fromEpochMilliseconds(at), isAllDay = false, timeZone = "UTC")
@Test
fun `a provider series whose start moved later has moved on`() {
assertThat(SubtaskReset.movedOn(state(start), state(start + week), spec(start + week))).isTrue()
assertThat(SubtaskReset.movedOn(state(null, due = start), state(null, due = start + week), spec(start + week))).isTrue()
}
@Test
fun `ticking a later occurrence only adds an exception and moves nothing`() {
val after = state(start, exdate = "20261109T080000Z")
assertThat(SubtaskReset.movedOn(state(start), after, spec(start))).isFalse()
}
@Test
fun `a provider series ticked on its last occurrence is finished`() {
// OpenTasks deletes the master or drops its rule: there is no series state after.
assertThat(SubtaskReset.movedOn(state(start), null, null)).isFalse()
// Closed through its status.
assertThat(SubtaskReset.movedOn(state(start), state(start + week, TasksContract.STATUS_COMPLETED), spec(start + week))).isFalse()
// Dates moved, but the rule has nothing left from there.
val ended = spec(start + week, rrule = "FREQ=WEEKLY;UNTIL=20261103T080000Z")
assertThat(SubtaskReset.movedOn(state(start), state(start + week), ended)).isFalse()
assertThat(SubtaskReset.movedOn(state(start), state(start + week), null)).isFalse()
}
@Test
fun `a provider series moved earlier or without dates has not moved on`() {
assertThat(SubtaskReset.movedOn(state(start), state(start - week), spec(start - week))).isFalse()
assertThat(SubtaskReset.movedOn(state(null), state(null), spec(start))).isFalse()
assertThat(SubtaskReset.movedOn(null, state(start + week), spec(start + week))).isFalse()
}
}
@@ -0,0 +1,33 @@
package de.jeanlucmakiola.agendula.data.tasks
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class TickedCopiesTest {
private val now = 100L * 24 * 60 * 60 * 1000
private fun tick(copy: Long, daysAgo: Long) = Tick(copy, seriesId = 1, before = null, after = null, at = now - daysAgo * 24 * 60 * 60 * 1000)
@Test
fun `a tick past the horizon goes, and its copy stays known`() {
val ticks = mapOf(
"org.dmfs.tasks|1|10" to tick(copy = 7, daysAgo = 31),
"org.dmfs.tasks|1|20" to tick(copy = 8, daysAgo = 2),
"org.tasks.opentasks|5|10" to tick(copy = 9, daysAgo = 40),
"garbage" to null,
)
val (stale, known) = TickedCopies.expire(ticks, now)
assertThat(stale).containsExactly("org.dmfs.tasks|1|10", "org.tasks.opentasks|5|10", "garbage")
assertThat(known).containsExactly("org.dmfs.tasks", setOf(7L), "org.tasks.opentasks", setOf(9L))
}
@Test
fun `nothing expires inside the horizon`() {
val (stale, known) = TickedCopies.expire(mapOf("a|1|1" to tick(copy = 3, daysAgo = 29)), now)
assertThat(stale).isEmpty()
assertThat(known).isEmpty()
}
}