diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a65170..26e197b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ae906e1..478ffaa 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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" diff --git a/app/schemas/de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase/5.json b/app/schemas/de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase/5.json new file mode 100644 index 0000000..30c5361 --- /dev/null +++ b/app/schemas/de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase/5.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/ExternalProviderListOrderTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/ExternalProviderListOrderTest.kt new file mode 100644 index 0000000..5262d6a --- /dev/null +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/ExternalProviderListOrderTest.kt @@ -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() + private val resolver = ProviderResolver(AndroidProviderEnvironment(context)).apply { + storageMode = StorageMode.EXTERNAL + } + private val source = AndroidTasksDataSource(context, resolver) + private val created = mutableListOf() + + @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) + } +} diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/ExternalProviderRecurrenceTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/ExternalProviderRecurrenceTest.kt new file mode 100644 index 0000000..bd8e05d --- /dev/null +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/ExternalProviderRecurrenceTest.kt @@ -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 org.tasks.permission.READ_TASKS` (and WRITE_TASKS). + */ +@RunWith(AndroidJUnit4::class) +class ExternalProviderRecurrenceTest { + + private val context = ApplicationProvider.getApplicationContext() + 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): 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) + } +} diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/provider/AgendulaTaskProviderTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/provider/AgendulaTaskProviderTest.kt index fd0f678..5e82779 100644 --- a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/provider/AgendulaTaskProviderTest.kt +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/provider/AgendulaTaskProviderTest.kt @@ -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 diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderWriterTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderWriterTest.kt index f4383a9..0348c59 100644 --- a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderWriterTest.kt +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderWriterTest.kt @@ -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 { val columns = table.columns.map { it.first } diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSourceTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSourceTest.kt index 75a3a9e..4dcfedb 100644 --- a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSourceTest.kt +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSourceTest.kt @@ -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) diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseMigrationTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseMigrationTest.kt index 9e37f7e..ed7a030 100644 --- a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseMigrationTest.kt +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseMigrationTest.kt @@ -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" } diff --git a/app/src/androidTestFull/java/de/jeanlucmakiola/agendula/data/sync/RoomSyncStoreAdvancesTest.kt b/app/src/androidTestFull/java/de/jeanlucmakiola/agendula/data/sync/RoomSyncStoreAdvancesTest.kt new file mode 100644 index 0000000..fd8f135 --- /dev/null +++ b/app/src/androidTestFull/java/de/jeanlucmakiola/agendula/data/sync/RoomSyncStoreAdvancesTest.kt @@ -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(), 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() + } +} diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/CollectionSyncer.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/CollectionSyncer.kt index a54e999..2815af9 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/CollectionSyncer.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/CollectionSyncer.kt @@ -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), diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncStore.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncStore.kt index 0cb0544..1dd6acb 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncStore.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncStore.kt @@ -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) /** - * 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, href: String?, eTag: String?) + fun markSynced(rows: List, 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, 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, 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?) { diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt index 7190753..26a4a9e 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt @@ -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() /** diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt index 958a0d5..67fa2b4 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt @@ -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 { - 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 { 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 = + ListOrder.decode(listOrder.getString(authority, null)) + + private val listOrder get() = context.getSharedPreferences(LIST_ORDER, Context.MODE_PRIVATE) + + private fun storeListOrder(authority: String, order: Map) { + 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) { + 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 { val clauses = mutableListOf() val args = mutableListOf() @@ -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 = @@ -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?): List { - 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 = enrich(instanceRows(cursor)) + + /** [cursor]'s instance rows, before their rules and tags are looked up. */ + private fun instanceRows(cursor: Cursor?): List { + 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): List { + 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): Map = 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 queryByIds( + uri: Uri, + projection: Array, + idColumn: String, + ids: Collection, + filter: String? = null, + filterArgs: List = emptyList(), + sortOrder: String? = null, + read: (ColumnReader) -> T?, + ): List = 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 { // 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?) { - 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?) { - 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?) { + 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 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 { + while (c.moveToNext()) { + val id = r.getLong(Tasks.ID) ?: continue + val values = buildMap { + 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) { @@ -430,29 +659,19 @@ class AndroidTasksDataSource @Inject constructor( } /** Tags by task id for [taskIds], in stored order. */ - private fun categories(taskIds: Collection): Map> { - 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): Map> = 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? { + 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 diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ListOrder.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ListOrder.kt new file mode 100644 index 0000000..1627489 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ListOrder.kt @@ -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 = 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): 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, listIds: List): Map { + 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, order: Map): List = lists.sortedWith( + compareBy({ it.accountName.lowercase() }, { order[it.id] ?: 0 }, { it.name.lowercase() }) + .thenBy { it.name }, + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ModeRoutingTasksDataSource.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ModeRoutingTasksDataSource.kt index cb0ef65..7f3eb5a 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ModeRoutingTasksDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ModeRoutingTasksDataSource.kt @@ -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) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapper.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapper.kt index e5d810f..6dfed86 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapper.kt @@ -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?) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapper.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapper.kt index 7413e12..e8512f2 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapper.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksDataSource.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksDataSource.kt index e516b93..f70b51a 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksDataSource.kt @@ -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]. */ diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt index 5f77c29..f747de2 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt @@ -68,9 +68,11 @@ class TasksRepositoryImpl @Inject constructor( override fun taskDetail(taskId: Long, occurrenceStart: Instant?): Flow = 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. */ diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TickedCopies.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TickedCopies.kt new file mode 100644 index 0000000..23092e4 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TickedCopies.kt @@ -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?, + val after: Map?, + 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? = + 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) = 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 = 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 + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderWriter.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderWriter.kt index b262286..835ab12 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderWriter.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderWriter.kt @@ -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?): 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?): 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?): 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?): 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 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 } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/AdvancedOccurrence.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/AdvancedOccurrence.kt new file mode 100644 index 0000000..18f1001 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/AdvancedOccurrence.kt @@ -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 + get() = droppedExdates.split(',').mapNotNull { it.toLongOrNull()?.let(Instant::fromEpochMilliseconds) } + + companion object { + fun encode(instants: List) = 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) + + @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 + + @Query("SELECT * FROM advanced_occurrences WHERE master_id = :masterId") + fun forMaster(masterId: Long): List + + /** 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) + + /** 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) +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapper.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapper.kt index fb67ce8..3bf4214 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapper.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSource.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSource.kt index 60edba7..8990084 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSource.kt @@ -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?) { - if (reminders == null) return - alarms.syncForTask(taskId, reminders) - } + private fun writeReminders(taskId: Long, reminders: List?): Boolean = + reminders != null && alarms.syncForTask(taskId, reminders) private fun storedReminders(taskId: Long): List = 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?) = 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 { + 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) = 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 { + 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)) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskDao.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskDao.kt index 747ef2d..8bdffa5 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskDao.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskDao.kt @@ -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, 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?) } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriter.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriter.kt index a73b2c0..c7743f0 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriter.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriter.kt @@ -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 = + 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> = + 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 { diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskListDao.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskListDao.kt index a5d6bd8..fc71614 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskListDao.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskListDao.kt @@ -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 @@ -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? diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabase.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabase.kt index 6e24f41..6d81763 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabase.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabase.kt @@ -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`)", + ) + } + } } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImport.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImport.kt index 9999e14..b08107b 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImport.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImport.kt @@ -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, ), ) } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/Models.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/Models.kt index 35de203..55943f9 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/domain/Models.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/Models.kt @@ -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 diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ListOrderTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ListOrderTest.kt new file mode 100644 index 0000000..0695ab3 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ListOrderTest.kt @@ -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() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapperTest.kt index 781b02a..4f9ee43 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapperTest.kt @@ -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) = - 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) diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapperTest.kt index 2ede4d0..4c19139 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapperTest.kt @@ -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 diff --git a/app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/SyncFakes.kt b/app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/SyncFakes.kt index 0b18304..a563d46 100644 --- a/app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/SyncFakes.kt +++ b/app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/SyncFakes.kt @@ -76,11 +76,12 @@ class FakeStore(rows: List = emptyList()) : SyncStore { return id } - override fun markSynced(taskIds: List, href: String?, eTag: String?) { - taskIds.forEach { id -> - val index = rows.indexOfFirst { it.id == id } + override fun markSynced(rows: List, 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) } } } diff --git a/fastlane/metadata/android/ar/changelogs/1030199.txt b/fastlane/metadata/android/ar/changelogs/1030199.txt new file mode 100644 index 0000000..0263fb5 --- /dev/null +++ b/fastlane/metadata/android/ar/changelogs/1030199.txt @@ -0,0 +1,6 @@ +### تغييرات +- إنجاز مهمة متكررة ينقلها إلى موعدها التالي ويحتفظ بنسخة منجزة، فتراها التطبيقات الأخرى وواجهات الويب للخوادم منجزة. + +### إصلاحات +- مع tasks.org تحتفظ المهام المتكررة بقاعدتها: تعديلها لم يعد يحذفها وإنجازها لم يعد ينهي السلسلة كلها. +- مع tasks.org وOpenTasks يعرض المحرر التكرار ويُحفظ ترتيب القوائم. diff --git a/fastlane/metadata/android/cs-CZ/changelogs/1030199.txt b/fastlane/metadata/android/cs-CZ/changelogs/1030199.txt new file mode 100644 index 0000000..b3cca23 --- /dev/null +++ b/fastlane/metadata/android/cs-CZ/changelogs/1030199.txt @@ -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. diff --git a/fastlane/metadata/android/de-DE/changelogs/1030199.txt b/fastlane/metadata/android/de-DE/changelogs/1030199.txt new file mode 100644 index 0000000..d9188b3 --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/1030199.txt @@ -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. diff --git a/fastlane/metadata/android/en-GB/changelogs/1030199.txt b/fastlane/metadata/android/en-GB/changelogs/1030199.txt new file mode 100644 index 0000000..3ca3723 --- /dev/null +++ b/fastlane/metadata/android/en-GB/changelogs/1030199.txt @@ -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. diff --git a/fastlane/metadata/android/en-US/changelogs/1030199.txt b/fastlane/metadata/android/en-US/changelogs/1030199.txt new file mode 100644 index 0000000..3ca3723 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/1030199.txt @@ -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. diff --git a/fastlane/metadata/android/fr-FR/changelogs/1030199.txt b/fastlane/metadata/android/fr-FR/changelogs/1030199.txt new file mode 100644 index 0000000..6bac517 --- /dev/null +++ b/fastlane/metadata/android/fr-FR/changelogs/1030199.txt @@ -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é. diff --git a/fastlane/metadata/android/pt-BR/changelogs/1030199.txt b/fastlane/metadata/android/pt-BR/changelogs/1030199.txt new file mode 100644 index 0000000..aad0679 --- /dev/null +++ b/fastlane/metadata/android/pt-BR/changelogs/1030199.txt @@ -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. diff --git a/fastlane/metadata/android/ru-RU/changelogs/1030199.txt b/fastlane/metadata/android/ru-RU/changelogs/1030199.txt new file mode 100644 index 0000000..3cc9137 --- /dev/null +++ b/fastlane/metadata/android/ru-RU/changelogs/1030199.txt @@ -0,0 +1,6 @@ +### Изменения +- Отметка повторяющейся задачи переносит её на следующую дату и сохраняет выполненную копию, так что другие приложения и веб-интерфейсы серверов видят её выполненной. + +### Исправления +- С tasks.org повторяющиеся задачи сохраняют правило: правка больше его не удаляет, а отметка не завершает всю серию. +- С tasks.org и OpenTasks редактор показывает повторение, а порядок списков сохраняется.