diff --git a/CHANGELOG.md b/CHANGELOG.md index 03a357a..cfefb89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] ### Added +- A reminder can count from the task's start as well as from its due date, and + tasks with only a start date get reminders too. Default reminders can mix + both, e.g. a day before the start and an hour before the due date; each + applies to tasks that have its date (#72). +- An all-day task's reminders can each have their own time, so one task can + remind you at 09:00 and again at 17:00 (#78). - Other apps can read the tasks kept in Agendula's own database through an OpenTasks-compatible provider, once you grant them permission. Apps that already support OpenTasks or tasks.org need only a small change to support @@ -17,6 +23,27 @@ All notable changes to this project are documented here. The format follows occurrence of a recurring task. Task lists stay read-only for them (#63). ### Changed +- A new task no longer takes a default reminder whose time has already + passed, so a start of "today" does not ring the moment the task is saved. +- All-day default reminders are whole days; one set in hours rounds to the + nearest day. +- Switching a task between timed and all-day converts its reminders right + away in the editor, so what you see is what gets saved. +- The editor shows the default reminders a new task, or a newly added date, + will get, before you save. Removing them leaves the task without a reminder + instead of falling back to the default. +- Reminders now sync. A reminder set on a task is uploaded as a standard alarm + (`VALARM`), so it also notifies in macOS and iOS Reminders, Thunderbird, + tasks.org and jtx Board, and alarms set in those apps now fire in Agendula + instead of the default reminder. Removing a task's alarm in another app + turns it off here too, rather than falling back to the default (#52). +- A new task with a date, or one that gets a new date, takes the default + reminder for that date as its own, so it travels with the task to the server. + Changing the default later no longer changes those tasks; tasks that rely on + the default today are left as they are. +- On upgrade, reminders on open, synced tasks that so far stayed on this + device are uploaded once. +- Alarms imported from an `.ics` file become reminders on the task. - Screens now open and close the way they do in Calendula: they slide in, and a back swipe previews the screen behind before closing (#66). - Settings now use Calendula's names and layout. Reminders is now diff --git a/README.md b/README.md index 63c9506..d135c61 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,8 @@ many small *florets* — so the two apps are florets of one bloom.) this and the following ones, or the whole series. - **Reminders** that fire at the exact time: several per task, separate defaults for timed and all-day tasks (globally and per list), Done and Snooze right on - the notification, and they survive reboots. + the notification, and they survive reboots. They sync as standard `VALARM`s, + so they also ring in other CalDAV clients, and theirs ring here. - **iCalendar import and export** — open an `.ics` or a zip of them, or write any list out as standard `.ics` files. - A **home-screen widget**, launcher shortcuts, a Quick Settings tile, and diff --git a/app/schemas/de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase/3.json b/app/schemas/de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase/3.json new file mode 100644 index 0000000..4bc7c6a --- /dev/null +++ b/app/schemas/de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase/3.json @@ -0,0 +1,548 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "725292b38bce25ce9032c4650573ae85", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `display_name` TEXT NOT NULL, `principal_url` TEXT, `home_set_url` TEXT, `username` TEXT, `last_sync_at` INTEGER, `last_sync_error` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "principalUrl", + "columnName": "principal_url", + "affinity": "TEXT" + }, + { + "fieldPath": "homeSetUrl", + "columnName": "home_set_url", + "affinity": "TEXT" + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "lastSyncAt", + "columnName": "last_sync_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastSyncError", + "columnName": "last_sync_error", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "task_lists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `account_id` INTEGER, `is_visible` INTEGER NOT NULL DEFAULT 1, `is_synced` INTEGER NOT NULL DEFAULT 1, `owner` TEXT, `is_read_only` INTEGER NOT NULL DEFAULT 0, `sort_order` INTEGER NOT NULL DEFAULT 0, `href` TEXT, `ctag` TEXT, `sync_token` TEXT, `is_dirty` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`account_id`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "account_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "isVisible", + "columnName": "is_visible", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "isSynced", + "columnName": "is_synced", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "owner", + "columnName": "owner", + "affinity": "TEXT" + }, + { + "fieldPath": "isReadOnly", + "columnName": "is_read_only", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "sortOrder", + "columnName": "sort_order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "href", + "columnName": "href", + "affinity": "TEXT" + }, + { + "fieldPath": "ctag", + "columnName": "ctag", + "affinity": "TEXT" + }, + { + "fieldPath": "syncToken", + "columnName": "sync_token", + "affinity": "TEXT" + }, + { + "fieldPath": "isDirty", + "columnName": "is_dirty", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_task_lists_account_id", + "unique": false, + "columnNames": [ + "account_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_task_lists_account_id` ON `${TABLE_NAME}` (`account_id`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "account_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "tasks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `list_id` INTEGER NOT NULL, `uid` TEXT NOT NULL, `href` TEXT, `etag` TEXT, `title` TEXT, `description` TEXT, `location` TEXT, `url` TEXT, `color` INTEGER, `categories` TEXT NOT NULL DEFAULT '', `status` INTEGER NOT NULL DEFAULT 0, `percent_complete` INTEGER, `completed_at` INTEGER, `priority` INTEGER NOT NULL DEFAULT 0, `classification` INTEGER, `dtstart` INTEGER, `due` INTEGER, `duration` TEXT, `is_all_day` INTEGER NOT NULL DEFAULT 0, `timezone` TEXT, `rrule` TEXT, `rdate` TEXT, `exdate` TEXT, `recurrence_id` INTEGER, `master_id` INTEGER, `parent_id` INTEGER, `sort_order` INTEGER NOT NULL DEFAULT 0, `created_at` INTEGER, `last_modified` INTEGER, `sequence` INTEGER NOT NULL DEFAULT 0, `is_dirty` INTEGER NOT NULL DEFAULT 0, `is_deleted` INTEGER NOT NULL DEFAULT 0, `unknown_properties` TEXT, `reminders_off` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`list_id`) REFERENCES `task_lists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`master_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parent_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "listId", + "columnName": "list_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "href", + "columnName": "href", + "affinity": "TEXT" + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "location", + "columnName": "location", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT" + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER" + }, + { + "fieldPath": "categories", + "columnName": "categories", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "percentComplete", + "columnName": "percent_complete", + "affinity": "INTEGER" + }, + { + "fieldPath": "completedAt", + "columnName": "completed_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "classification", + "columnName": "classification", + "affinity": "INTEGER" + }, + { + "fieldPath": "dtstart", + "columnName": "dtstart", + "affinity": "INTEGER" + }, + { + "fieldPath": "due", + "columnName": "due", + "affinity": "INTEGER" + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "TEXT" + }, + { + "fieldPath": "isAllDay", + "columnName": "is_all_day", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "timezone", + "columnName": "timezone", + "affinity": "TEXT" + }, + { + "fieldPath": "rrule", + "columnName": "rrule", + "affinity": "TEXT" + }, + { + "fieldPath": "rdate", + "columnName": "rdate", + "affinity": "TEXT" + }, + { + "fieldPath": "exdate", + "columnName": "exdate", + "affinity": "TEXT" + }, + { + "fieldPath": "recurrenceId", + "columnName": "recurrence_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "masterId", + "columnName": "master_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "sortOrder", + "columnName": "sort_order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastModified", + "columnName": "last_modified", + "affinity": "INTEGER" + }, + { + "fieldPath": "sequence", + "columnName": "sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isDirty", + "columnName": "is_dirty", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isDeleted", + "columnName": "is_deleted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "unknownProperties", + "columnName": "unknown_properties", + "affinity": "TEXT" + }, + { + "fieldPath": "remindersOff", + "columnName": "reminders_off", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_tasks_list_id_is_deleted", + "unique": false, + "columnNames": [ + "list_id", + "is_deleted" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_list_id_is_deleted` ON `${TABLE_NAME}` (`list_id`, `is_deleted`)" + }, + { + "name": "index_tasks_parent_id", + "unique": false, + "columnNames": [ + "parent_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_parent_id` ON `${TABLE_NAME}` (`parent_id`)" + }, + { + "name": "index_tasks_master_id_recurrence_id", + "unique": false, + "columnNames": [ + "master_id", + "recurrence_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_master_id_recurrence_id` ON `${TABLE_NAME}` (`master_id`, `recurrence_id`)" + }, + { + "name": "index_tasks_is_dirty", + "unique": false, + "columnNames": [ + "is_dirty" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_is_dirty` ON `${TABLE_NAME}` (`is_dirty`)" + }, + { + "name": "index_tasks_list_id_uid_recurrence_id", + "unique": true, + "columnNames": [ + "list_id", + "uid", + "recurrence_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tasks_list_id_uid_recurrence_id` ON `${TABLE_NAME}` (`list_id`, `uid`, `recurrence_id`)" + } + ], + "foreignKeys": [ + { + "table": "task_lists", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "list_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "tasks", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "master_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "tasks", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "parent_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "task_alarms", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `task_id` INTEGER NOT NULL, `minutes_before` INTEGER NOT NULL, `reference` TEXT NOT NULL DEFAULT 'DUE', `message` TEXT, `uid` TEXT NOT NULL DEFAULT '', `raw` TEXT, FOREIGN KEY(`task_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "taskId", + "columnName": "task_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minutesBefore", + "columnName": "minutes_before", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reference", + "columnName": "reference", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'DUE'" + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT" + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "raw", + "columnName": "raw", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_task_alarms_task_id", + "unique": false, + "columnNames": [ + "task_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_task_alarms_task_id` ON `${TABLE_NAME}` (`task_id`)" + } + ], + "foreignKeys": [ + { + "table": "tasks", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "task_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '725292b38bce25ce9032c4650573ae85')" + ] + } +} \ No newline at end of file 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 35665d1..75a3a9e 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 @@ -5,7 +5,7 @@ import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.agendula.data.tasks.TaskQuery -import de.jeanlucmakiola.agendula.data.tasks.TaskReminder +import de.jeanlucmakiola.agendula.domain.TaskReminder import de.jeanlucmakiola.agendula.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskStatus import org.junit.After @@ -366,41 +366,23 @@ class RoomTasksDataSourceTest { @Test fun alarmsRoundTripAndReplaceRatherThanAccumulate() { val id = source.insertTask(form(due = now + 1.days)) + val start = TaskReminder(minutesBefore = 10, fromStart = true) // The whole reminder, not just the minute count: collapsing it to a bare - // Int is what fired an imported START-referenced alarm off DUE, and an - // alarm this seam sets from the UI is always due-referenced. - source.setAlarm(id, 30) - assertThat(source.alarms()[id]).isEqualTo(TaskReminder(minutesBefore = 30)) - - source.setAlarm(id, 60) - assertThat(db.alarms().forTask(id)).hasSize(1) - assertThat(source.alarms()[id]).isEqualTo(TaskReminder(minutesBefore = 60)) - - source.setAlarm(id, null) - assertThat(source.alarms()).doesNotContainKey(id) - } - - @Test - fun settingTheEditableReminderLeavesTheOthersAlone() { - val id = source.insertTask(form(due = now + 1.days)) - val start = TaskReminder(minutesBefore = 10, fromStart = true) - source.setReminders(id, listOf(start, TaskReminder(30), TaskReminder(120))) - - source.setAlarm(id, 45) - assertThat(source.reminders()[id]).containsExactly(start, TaskReminder(30), TaskReminder(45)).inOrder() - assertThat(source.alarms()[id]).isEqualTo(TaskReminder(45)) - - source.setAlarm(id, null) + // Int is what fired an imported START-referenced alarm off DUE. + source.setReminders(id, listOf(start, TaskReminder(30))) assertThat(source.reminders()[id]).containsExactly(start, TaskReminder(30)).inOrder() - assertThat(source.alarms()[id]).isEqualTo(TaskReminder(30)) + + source.setReminders(id, listOf(TaskReminder(60))) + assertThat(db.alarms().forTask(id)).hasSize(1) + assertThat(source.reminders()[id]).containsExactly(TaskReminder(60)) } @Test fun forkingAnOccurrenceCarriesTheReminderOntoIt() { val id = source.insertTask(form(due = now + 1.days)) makeRecurring(id, now) - source.setAlarm(id, 30) + source.setReminders(id, listOf(TaskReminder(30))) val target = source.tasks(TaskQuery(listId = listId)) .filter { it.taskId == id } .first { it.distanceFromCurrent == 1 } @@ -411,6 +393,134 @@ class RoomTasksDataSourceTest { assertThat(db.alarms().forTask(override.id).single().minutesBefore).isEqualTo(30) } + @Test + fun aSaveKeepsTheIdentityOfAReminderItDidNotChange() { + val id = source.insertTask(form(due = now + 1.days), listOf(TaskReminder(15))) + val original = db.alarms().forTask(id).single() + db.alarms().update(original.copy(raw = "BEGIN:VALARM\r\nX-VENDOR:keep\r\nEND:VALARM\r\n")) + + source.updateTask(id, form(title = "renamed", due = now + 2.days), reminders = listOf(TaskReminder(15), TaskReminder(60))) + + val rows = db.alarms().forTask(id) + val kept = rows.single { it.minutesBefore == 15 } + // Same row, same identity on the server, same original text. + assertThat(kept.id).isEqualTo(original.id) + assertThat(kept.uid).isEqualTo(original.uid) + assertThat(kept.raw).contains("X-VENDOR:keep") + assertThat(rows.single { it.minutesBefore == 60 }.uid).isNotEqualTo(original.uid) + } + + @Test + fun aSaveWritesBeforeStartAndBeforeDueRemindersAlike() { + val id = source.insertTask(form(due = now + 1.days)) + source.setReminders(id, listOf(TaskReminder(10, fromStart = true), TaskReminder(30))) + + source.updateTask(id, form(due = now + 1.days), reminders = listOf(TaskReminder(5, fromStart = true), TaskReminder(45))) + + assertThat(source.reminders()[id]).containsExactly(TaskReminder(5, fromStart = true), TaskReminder(45)) + } + + @Test + fun aReminderForOneOccurrenceLeavesTheSeriesUntouched() { + val work = syncedList() + val id = source.insertTask(form(due = now + 1.days).copy(listId = work), listOf(TaskReminder(30))) + makeRecurring(id, now) + db.tasks().update(db.tasks().entity(id)!!.copy(isDirty = false)) + val target = source.tasks(TaskQuery(listId = work)) + .filter { it.taskId == id } + .first { it.distanceFromCurrent == 1 } + + source.updateInstance(id, target.occurrenceStart!!, form(title = "moved").copy(listId = work), listOf(TaskReminder(60))) + + // ⚠️ The master is never written, not even briefly: a sync landing in + // between would upload the occurrence's reminder for the whole series. + assertThat(db.alarms().forTask(id).map { it.minutesBefore }).containsExactly(30) + assertThat(db.tasks().entity(id)!!.isDirty).isFalse() + val override = db.tasks().override(id, target.occurrenceStart)!! + assertThat(db.alarms().forTask(override.id).map { it.minutesBefore }).containsExactly(60) + } + + @Test + fun splittingASeriesGivesTheNewSeriesItsOwnReminderIdentities() { + val id = source.insertTask(form(due = now + 1.days), listOf(TaskReminder(30))) + makeRecurring(id, now - 14.days) + val master = db.alarms().forTask(id).single() + val target = source.tasks(TaskQuery(listId = listId)) + .filter { it.taskId == id } + .first { it.distanceFromCurrent == 1 } + + val created = source.splitSeries(id, target.occurrenceStart!!, form(title = "later"), listOf(TaskReminder(30))) + + assertThat(created).isNotEqualTo(id) + val copy = db.alarms().forTask(created).single() + assertThat(copy.minutesBefore).isEqualTo(30) + // A different UID on the server, so the alarm must not share one either. + assertThat(copy.uid).isNotEqualTo(master.uid) + assertThat(db.alarms().forTask(id).single().uid).isEqualTo(master.uid) + } + + @Test + fun changingOnlyTheRemindersOwesTheServerAPut() { + val work = syncedList() + val id = source.insertTask(form(due = now + 1.days).copy(listId = work)) + db.tasks().update(db.tasks().entity(id)!!.copy(isDirty = false)) + + source.setReminders(id, listOf(TaskReminder(15))) + assertThat(db.tasks().entity(id)!!.isDirty).isTrue() + + db.tasks().update(db.tasks().entity(id)!!.copy(isDirty = false)) + source.setReminders(id, listOf(TaskReminder(15))) + // Nothing changed, so nothing is owed. + assertThat(db.tasks().entity(id)!!.isDirty).isFalse() + } + + @Test + fun aSeriesReminderEditReachesTheOccurrencesThatStillHadTheOldOne() { + val id = source.insertTask(form(due = now + 1.days), listOf(TaskReminder(15))) + makeRecurring(id, now) + val occurrences = source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id } + val completed = occurrences.first { it.distanceFromCurrent == 1 }.occurrenceStart!! + val own = occurrences.first { it.distanceFromCurrent == 2 }.occurrenceStart!! + source.setCompletedInstance(id, completed, true) + source.updateInstance(id, own, form(title = "own"), listOf(TaskReminder(5))) + + // Same times and rule, so the series is not reshaped — that drops its overrides. + val first = occurrences.first() + val unchanged = form().copy(start = first.start, due = first.due, rrule = "FREQ=WEEKLY") + source.updateSeries(id, first.occurrenceStart!!, unchanged, listOf(TaskReminder(60))) + + fun leads(anchor: Instant) = db.alarms().forTask(db.tasks().override(id, anchor)!!.id).map { it.minutesBefore } + assertThat(db.alarms().forTask(id).map { it.minutesBefore }).containsExactly(60) + // A snapshot of the series' reminder follows it… + assertThat(leads(completed)).containsExactly(60) + // …one set for that occurrence alone does not. + assertThat(leads(own)).containsExactly(5) + } + + @Test + fun aTaskWhoseAlarmsWereTurnedOffElsewhereReportsNone() { + val id = source.insertTask(form(due = now + 1.days)) + db.alarms().setRemindersOff(id, true) + + assertThat(source.reminders()[id]).isEmpty() + assertThat(source.reminders()).containsKey(id) + + // A reminder of its own again: the flag goes. + source.setReminders(id, listOf(TaskReminder(15))) + assertThat(db.tasks().entity(id)!!.remindersOff).isFalse() + } + + @Test + fun settingRemindersOwesTheServerAPut() { + val work = syncedList() + val id = source.insertTask(form(due = now + 1.days).copy(listId = work)) + db.tasks().update(db.tasks().entity(id)!!.copy(isDirty = false)) + + source.setReminders(id, listOf(TaskReminder(30))) + + assertThat(db.tasks().entity(id)!!.isDirty).isTrue() + } + @Test fun deletingATaskInALocalListRemovesItOutright() { val id = source.insertTask(form()) @@ -439,7 +549,7 @@ class RoomTasksDataSourceTest { fun aForkedOccurrenceCarriesTheSeriesReminder() { val id = source.insertTask(form()) makeRecurring(id, now) - source.setAlarm(id, minutesBeforeDue = 30) + source.setReminders(id, listOf(TaskReminder(30))) val target = source.tasks(TaskQuery(listId = listId)) .filter { it.taskId == id } .first { it.distanceFromCurrent == 1 } @@ -447,7 +557,7 @@ class RoomTasksDataSourceTest { source.updateInstance(id, target.occurrenceStart!!, form(title = "moved")) // The fork copies the master's properties, which is what carries the - // reminder across — the repository is what puts the series' own back. + // reminder across. val override = db.tasks().override(id, target.occurrenceStart)!! assertThat(db.alarms().forTask(override.id).single().minutesBefore).isEqualTo(30) } 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 ca0d824..a6fa376 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 @@ -75,6 +75,78 @@ class TasksDatabaseMigrationTest { } } + @Test + fun liftsClaimableAlarmsAndPushesRemindersThatNeverLeftTheDevice() { + val future = System.currentTimeMillis() + 7L * 24 * 60 * 60 * 1000 + val past = System.currentTimeMillis() - 7L * 24 * 60 * 60 * 1000 + val claimable = "X-FOO:bar\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nX-WR-ALARMUID:srv\r\n" + + "TRIGGER;RELATED=END:-PT15M\r\nEND:VALARM\r\n" + val appleDefault = "BEGIN:VALARM\r\nACTION:DISPLAY\r\nX-APPLE-DEFAULT-ALARM:TRUE\r\n" + + "TRIGGER;RELATED=END:-PT30M\r\nEND:VALARM\r\n" + helper.createDatabase(TEST_DB, 2).use { db -> + db.execSQL("INSERT INTO accounts (id, display_name) VALUES (1, 'me')") + db.execSQL("INSERT INTO task_lists (id, name, color, account_id) VALUES (1, 'Synced', 0, 1)") + db.execSQL("INSERT INTO task_lists (id, name, color) VALUES (2, 'Local', 0)") + db.execSQL("INSERT INTO task_lists (id, name, color, account_id, is_read_only) VALUES (3, 'Shared', 0, 1, 1)") + fun task(id: Int, list: Int, due: Long, residue: String? = null) = db.execSQL( + "INSERT INTO tasks (id, list_id, uid, title, due, unknown_properties) VALUES (?, ?, ?, ?, ?, ?)", + arrayOf(id, list, "uid-$id", "Task $id", due, residue), + ) + fun alarm(task: Int, minutes: Int) = + db.execSQL("INSERT INTO task_alarms (task_id, minutes_before) VALUES (?, ?)", arrayOf(task, minutes)) + // A server alarm, and the same lead copied locally by a fork. + task(1, 1, future, claimable) + alarm(1, 15) + // Set in Agendula, never uploaded — still to fire, already past, device-only. + task(2, 1, future) + alarm(2, 30) + task(3, 1, past) + alarm(3, 30) + task(4, 2, future) + alarm(4, 30) + // Somebody else's default alarm: not ours to claim. + task(5, 1, future, appleDefault) + // A read-only share: dirty would freeze it, never sent and never refreshed. + task(6, 3, future) + alarm(6, 30) + } + + helper.runMigrationsAndValidate(TEST_DB, 3, true, TasksDatabase.MIGRATION_2_3).use { db -> + db.query("SELECT task_id, minutes_before, uid, raw FROM task_alarms ORDER BY task_id").use { c -> + val rows = buildList { while (c.moveToNext()) add(listOf(c.getLong(0), c.getInt(1), c.getString(2), c.getString(3))) } + // One for task 1: the lifted copy won and the local duplicate went. + assertThat(rows.map { it[0] }).containsExactly(1L, 2L, 3L, 4L, 6L).inOrder() + assertThat(rows[0][2]).isEqualTo("srv") + assertThat(rows[0][3] as String).contains("TRIGGER;RELATED=END:-PT15M") + // Every pre-existing row got an identity of its own, shaped as a UUID. + assertThat(rows.drop(1).map { it[2] as String }.toSet()).hasSize(4) + rows.drop(1).forEach { + assertThat(it[2] as String).matches("[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}") + } + assertThat(rows.drop(1).all { (it[2] as String).isNotEmpty() && it[3] == null }).isTrue() + } + db.query("SELECT id, unknown_properties, is_dirty, last_modified, reminders_off FROM tasks ORDER BY id").use { c -> + c.moveToNext() + assertThat(c.getString(1)).isEqualTo("X-FOO:bar\r\n") + assertThat(c.getInt(2)).isEqualTo(0) + assertThat(c.getInt(4)).isEqualTo(0) + c.moveToNext() + assertThat(c.getInt(2)).isEqualTo(1) + // The body changes, so its modification stamp does too. + assertThat(c.isNull(3)).isFalse() + c.moveToNext() + assertThat(c.getInt(2)).isEqualTo(0) + c.moveToNext() + assertThat(c.getInt(2)).isEqualTo(0) + c.moveToNext() + assertThat(c.getString(1)).contains("X-APPLE-DEFAULT-ALARM:TRUE") + assertThat(c.getInt(2)).isEqualTo(0) + c.moveToNext() + assertThat(c.getInt(2)).isEqualTo(0) + } + } + } + private companion object { const val TEST_DB = "migration-test.db" } diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImportTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImportTest.kt index 8bc1eb3..1570592 100644 --- a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImportTest.kt +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImportTest.kt @@ -11,7 +11,7 @@ import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.agendula.data.tasks.ProviderEnvironment import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver import de.jeanlucmakiola.agendula.data.tasks.TaskQuery -import de.jeanlucmakiola.agendula.data.tasks.TaskReminder +import de.jeanlucmakiola.agendula.domain.TaskReminder import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase @@ -294,23 +294,25 @@ private class FakeExternalStore : TasksDataSource { return tasks[listId].orEmpty() } - override fun alarms(): Map = alarms + override fun reminders(): Map> = alarms.mapValues { listOf(it.value) } override fun tasks(query: TaskQuery): List = unused() override fun task(taskId: Long): Task? = unused() override fun subtasks(parentTaskId: Long): List = unused() - override fun insertTask(form: TaskForm): Long = unused() - override fun updateTask(taskId: Long, form: TaskForm) = unused() - override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) = unused() - override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) = unused() + override fun insertTask(form: TaskForm, reminders: List?): Long = unused() + override fun updateTask(taskId: Long, form: TaskForm, reminders: List?) = unused() + override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List?) = + unused() override fun setReminders(taskId: Long, reminders: List) = unused() override fun setCompleted(taskId: Long, completed: Boolean) = unused() override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) = unused() override fun deleteTask(taskId: Long) = unused() override fun setCancelled(taskId: Long, cancelled: Boolean) = unused() override fun setCancelledInstance(taskId: Long, occurrenceStart: Instant, cancelled: Boolean) = unused() - override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) = unused() - override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm): Long = unused() + override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List?) = + unused() + override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List?): Long = + unused() override fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant) = unused() override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) = unused() override fun createLocalList(name: String, color: Int): Long = unused() 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 4b46d42..a54e999 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 @@ -1,8 +1,10 @@ package de.jeanlucmakiola.agendula.data.sync +import de.jeanlucmakiola.agendula.data.tasks.ical.AlarmCodec import de.jeanlucmakiola.agendula.data.tasks.ical.CalendarResource import de.jeanlucmakiola.agendula.data.tasks.ical.ResourceValidator import de.jeanlucmakiola.agendula.data.tasks.ical.VTodoMapper +import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity import at.bitfire.dav4jvm.exception.UnauthorizedException @@ -422,8 +424,9 @@ class CollectionSyncer( /** Null when the resource was rejected before it left the device. */ private fun serialize(local: LocalResource): String? { + val alarms = store.alarmsFor(local.live.map { it.id }) val todos = local.live.map { row -> - VTodoMapper.write(row, parentUidOf(row), now()) + VTodoMapper.write(row, parentUidOf(row), now(), alarms[row.id].orEmpty()) } val calendar = CalendarResource.build(todos) @@ -778,14 +781,17 @@ class CollectionSyncer( val losing = existing.values.firstOrNull { it.isDirty } val master = mapped.firstOrNull { it.entity.recurrenceId == null } ?: mapped.first() - val masterRow = upsert(master.entity, existing[master.entity.recurrenceId], null, href, eTag) + val masterRow = upsert(master, existing[master.entity.recurrenceId], null, href, eTag) val masterId = masterRow.id parents[uid] = master.parentUid val written = mutableListOf(masterRow) mapped.filter { it !== master }.forEach { override -> + // ⚠️ An override's alarms are its own: RFC 5545 has an override + // replace the component, so one the server sends without a + // VALARM has none, whatever its master carries. written += upsert( - override.entity, existing[override.entity.recurrenceId], masterId, href, eTag, + override, existing[override.entity.recurrenceId], masterId, href, eTag, ) } val kept = written.map { it.recurrenceId }.toSet() @@ -832,12 +838,13 @@ class CollectionSyncer( /** @return the row as it now stands, so the caller can index it. */ private fun upsert( - incoming: TaskEntity, + mapped: VTodoMapper.Mapped, existing: TaskEntity?, masterId: Long?, href: String, eTag: String?, ): TaskEntity { + val incoming = mapped.entity // Local-only columns the server has no opinion about. Taking the // mapper's defaults here would silently reset the user's ordering and // per-task colour on every download. @@ -854,12 +861,42 @@ class CollectionSyncer( // set uploads what was just downloaded. isDirty = false, isDeleted = false, + // The store's to change, from what the alarms do. + remindersOff = existing?.remindersOff ?: false, ) - return if (existing == null) { - row.copy(id = store.insert(row)) - } else { - store.update(row) - row + // The server's alarms replace ours, as every other field does: a + // dirty row is never downloaded except to discard its edit. + val alarms = existing?.let { keepLocalPrecision(mapped.alarms, it, row) } ?: mapped.alarms + return row.copy(id = store.upsertWithAlarms(row, alarms)) + } + + /** + * [incoming] with each alarm we uploaded ourselves put back to the lead + * we hold for it. + * + * ⚠️ The wire is coarser than the row: a lead past the end of its + * anchor is clamped, and a before-start reminder on a task without a + * start goes out before-due. Taking the round trip at its word would + * quietly turn a before-start reminder into a before-due one the first + * time the task is downloaded again. + * An alarm that comes back as exactly what we would have sent has not + * been changed, so the row's own lead stands. + */ + private fun keepLocalPrecision( + incoming: List, + existing: TaskEntity, + task: TaskEntity, + ): List { + val local = store.alarmsFor(listOf(existing.id))[existing.id].orEmpty() + if (local.isEmpty()) return incoming + return incoming.map { alarm -> + val mine = local.firstOrNull { it.uid == alarm.uid } ?: return@map alarm + val sent = AlarmCodec.wireLead(mine, task) + if (sent == AlarmCodec.Lead(alarm.minutesBefore, alarm.reference)) { + alarm.copy(minutesBefore = mine.minutesBefore, reference = mine.reference) + } else { + alarm + } } } 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 b3940c5..0cb0544 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 @@ -1,5 +1,6 @@ package de.jeanlucmakiola.agendula.data.sync +import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase import javax.inject.Inject @@ -11,17 +12,33 @@ import javax.inject.Inject * discovery: the reconciliation above this interface is where local edits are * discarded, tombstones swept and conflicts resolved, and every one of those is * a decision that should be provable without a device. Room's test double is - * Robolectric plus an in-memory database; this is eight methods. + * Robolectric plus an in-memory database; this is a dozen methods. */ interface SyncStore { /** Every row in a list, **tombstones included**. */ fun rowsIn(listId: Long): List - fun insert(row: TaskEntity): Long - fun update(row: TaskEntity) + /** The reminders of [taskIds] — one resource's rows — by task id, for the body that uploads them. */ + fun alarmsFor(taskIds: List): Map> + + /** + * Writes a downloaded row — inserted when its id is 0 — and makes its + * reminders exactly [alarms], in one transaction. + * + * ⚠️ One transaction, not an [update] and a second call. The row lands clean + * with the server's ETag, so if the process died between the two, nothing + * would ever download it again and the reminders would stay stale for good. + * + * A row that is gone by the time it is written — deleted on the device + * while the sync ran — stays gone, reminders and all. + * + * @return the row's id. + */ + fun upsertWithAlarms(row: TaskEntity, alarms: List): Long + fun deleteAll(taskIds: List) /** @@ -56,12 +73,26 @@ class RoomSyncStore @Inject constructor( override fun rowsIn(listId: Long) = database.tasks().allIn(listId) - override fun insert(row: TaskEntity) = database.tasks().insert(row) - override fun update(row: TaskEntity) { database.tasks().update(row) } + override fun alarmsFor(taskIds: List) = + if (taskIds.isEmpty()) emptyMap() else database.alarms().forTasks(taskIds).groupBy { it.taskId } + + override fun upsertWithAlarms(row: TaskEntity, alarms: List): Long = + database.runInTransaction { + if (row.id == 0L) { + database.tasks().insert(row).also { database.alarms().replaceFromServer(it, alarms) } + } else { + // ⚠️ Zero rows means the row was deleted under us. Its alarms + // would then name a task that does not exist, and the foreign + // key would fail the whole transaction. + if (database.tasks().update(row) > 0) database.alarms().replaceFromServer(row.id, alarms) + row.id + } + } + override fun deleteAll(taskIds: List) { if (taskIds.isNotEmpty()) database.tasks().deleteAll(taskIds) } 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 65a744b..5d93f08 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) + .addMigrations(TasksDatabase.MIGRATION_1_2, TasksDatabase.MIGRATION_2_3) .build() /** diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt index 02a0c6c..15d6244 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt @@ -14,7 +14,11 @@ import de.jeanlucmakiola.agendula.domain.SavedFilter import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskFormField +import de.jeanlucmakiola.agendula.domain.TaskReminder +import de.jeanlucmakiola.agendula.domain.asTaskReminder +import de.jeanlucmakiola.agendula.domain.asReminderLead import de.jeanlucmakiola.agendula.domain.TaskSortOrder +import de.jeanlucmakiola.floret.reminders.ReminderLead import de.jeanlucmakiola.floret.reminders.ReminderOverride import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec import de.jeanlucmakiola.floret.reminders.applyReminderOverride @@ -57,10 +61,10 @@ data class Settings( val dynamicColor: Boolean = true, /** The list a new task defaults to; `null` = first available. */ val defaultListId: Long? = null, - /** Default reminders, as minutes before due (0 = at due time); empty = none. */ - val defaultReminderMinutes: List = listOf(0), - /** Default reminders for all-day tasks, as days-scale minutes before [allDayReminderMinuteOfDay]. */ - val defaultAllDayReminderMinutes: List = listOf(0), + /** Default reminders (0 before due = at due time); empty = none. */ + val defaultReminders: List = listOf(ReminderLead(0)), + /** Default reminders for all-day tasks, in whole days, rung at [allDayReminderMinuteOfDay]. */ + val defaultAllDayReminders: List = listOf(ReminderLead(0)), /** Local time (minutes from midnight) an all-day task's reminder counts back from. */ val allDayReminderMinuteOfDay: Int = DEFAULT_ALL_DAY_REMINDER_MINUTE, /** Master switch for due reminders; off clears every scheduled alarm. */ @@ -74,13 +78,13 @@ data class Settings( */ val bottomAddBar: Boolean = false, /** - * Per-list overrides of [defaultReminderMinutes]: a list present in the map + * Per-list overrides of [defaultReminders]: a list present in the map * overrides the global default (an empty list = no reminder); absent = * inherit. */ - val perListReminderOverride: Map> = emptyMap(), - /** Per-list overrides of [defaultAllDayReminderMinutes], same shape as [perListReminderOverride]. */ - val perListAllDayReminderOverride: Map> = emptyMap(), + val perListReminderOverride: Map> = emptyMap(), + /** Per-list overrides of [defaultAllDayReminders], same shape as [perListReminderOverride]. */ + val perListAllDayReminderOverride: Map> = emptyMap(), /** Optional edit-form fields shown by default; the rest sit behind "More fields". */ val defaultEditFields: Set = emptySet(), val sortOrder: TaskSortOrder = TaskSortOrder.DUE, @@ -109,12 +113,16 @@ data class Settings( } /** The lead times for a task in [listId]: its override if set, else the global default. */ - fun reminderLeadsFor(listId: Long): List = - perListReminderOverride.reminderLeadsFor(listId, defaultReminderMinutes) + fun reminderLeadsFor(listId: Long): List = + perListReminderOverride.reminderLeadsFor(listId, defaultReminders) /** The lead times for an all-day task in [listId]. */ - fun allDayReminderLeadsFor(listId: Long): List = - perListAllDayReminderOverride.reminderLeadsFor(listId, defaultAllDayReminderMinutes) + fun allDayReminderLeadsFor(listId: Long): List = + perListAllDayReminderOverride.reminderLeadsFor(listId, defaultAllDayReminders) + + /** The default reminders a task in [listId] takes — the one rule for which default applies. */ + fun defaultRemindersFor(listId: Long, allDay: Boolean): List = + (if (allDay) allDayReminderLeadsFor(listId) else reminderLeadsFor(listId)).map { it.asTaskReminder() } } /** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */ @@ -130,16 +138,18 @@ class SettingsPrefs @Inject constructor( dynamicColor = p[DYNAMIC_COLOR] ?: true, defaultListId = p[DEFAULT_LIST_ID]?.takeIf { it > 0 }, // The single lead of earlier versions carries over until a list is saved. - defaultReminderMinutes = p[DEFAULT_REMINDERS]?.let(::parseMinutes) - ?: listOf(p[REMINDER_LEAD] ?: 0), - defaultAllDayReminderMinutes = p[DEFAULT_ALL_DAY_REMINDERS]?.let(::parseMinutes) ?: listOf(0), + defaultReminders = p[DEFAULT_REMINDERS]?.let(::parseLeads) + ?: listOf(ReminderLead(p[REMINDER_LEAD] ?: 0)), + defaultAllDayReminders = p[DEFAULT_ALL_DAY_REMINDERS]?.let(::parseLeads)?.inWholeDays() + ?: listOf(ReminderLead(0)), allDayReminderMinuteOfDay = p[ALL_DAY_REMINDER_MINUTE]?.takeIf { it in 0 until 24 * 60 } ?: DEFAULT_ALL_DAY_REMINDER_MINUTE, remindersEnabled = p[REMINDERS_ENABLED] ?: true, showAddSubtaskRow = p[SHOW_ADD_SUBTASK_ROW] ?: true, bottomAddBar = p[BOTTOM_ADD_BAR] ?: false, - perListReminderOverride = reminderCodec.parse(p[LIST_REMINDER_OVERRIDE]), - perListAllDayReminderOverride = reminderCodec.parse(p[LIST_ALL_DAY_REMINDER_OVERRIDE]), + perListReminderOverride = reminderCodec.parseLeads(p[LIST_REMINDER_OVERRIDE]), + perListAllDayReminderOverride = reminderCodec.parseLeads(p[LIST_ALL_DAY_REMINDER_OVERRIDE]) + .mapValues { it.value.inWholeDays() }, defaultEditFields = p[DEFAULT_EDIT_FIELDS].orEmpty() .mapNotNull { name -> runCatching { TaskFormField.valueOf(name) }.getOrNull() } .toSet(), @@ -185,12 +195,12 @@ class SettingsPrefs @Inject constructor( if (id == null) it.remove(DEFAULT_LIST_ID) else it[DEFAULT_LIST_ID] = id } - suspend fun setDefaultReminderMinutes(minutes: List) = dataStore.edit { - it[DEFAULT_REMINDERS] = minutes.normalizeReminders().joinToString(",") + suspend fun setDefaultReminders(leads: List) = dataStore.edit { + it[DEFAULT_REMINDERS] = leads.normalizeReminders().joinToString(",") { lead -> lead.toToken() } } - suspend fun setDefaultAllDayReminderMinutes(minutes: List) = dataStore.edit { - it[DEFAULT_ALL_DAY_REMINDERS] = minutes.normalizeReminders().joinToString(",") + suspend fun setDefaultAllDayReminders(leads: List) = dataStore.edit { + it[DEFAULT_ALL_DAY_REMINDERS] = leads.normalizeReminders().joinToString(",") { lead -> lead.toToken() } } suspend fun setAllDayReminderMinuteOfDay(minuteOfDay: Int) = @@ -243,16 +253,16 @@ class SettingsPrefs @Inject constructor( /** Set (or clear, via [ReminderOverride.Inherit]) a list's reminder override. */ suspend fun setListReminderOverride(listId: Long, override: ReminderOverride) = dataStore.edit { p -> - val current = reminderCodec.parse(p[LIST_REMINDER_OVERRIDE]).toMutableMap() + val current = reminderCodec.parseLeads(p[LIST_REMINDER_OVERRIDE]).toMutableMap() current.applyReminderOverride(listId, override) - p[LIST_REMINDER_OVERRIDE] = reminderCodec.serialize(current) + p[LIST_REMINDER_OVERRIDE] = reminderCodec.serializeLeads(current) } /** Set (or clear) a list's all-day reminder override. */ suspend fun setListAllDayReminderOverride(listId: Long, override: ReminderOverride) = dataStore.edit { p -> - val current = reminderCodec.parse(p[LIST_ALL_DAY_REMINDER_OVERRIDE]).toMutableMap() + val current = reminderCodec.parseLeads(p[LIST_ALL_DAY_REMINDER_OVERRIDE]).toMutableMap() current.applyReminderOverride(listId, override) - p[LIST_ALL_DAY_REMINDER_OVERRIDE] = reminderCodec.serialize(current) + p[LIST_ALL_DAY_REMINDER_OVERRIDE] = reminderCodec.serializeLeads(current) } suspend fun setHiddenFromSmartLists(listId: Long, hidden: Boolean) = dataStore.edit { p -> @@ -330,6 +340,13 @@ class SettingsPrefs @Inject constructor( */ private val reminderCodec = ReminderOverrideCodec.DEFAULT -/** `5,30` → [5, 30]; an empty string is an explicit "no reminder". */ -private fun parseMinutes(stored: String): List = - stored.split(',').mapNotNull { it.trim().toIntOrNull()?.takeIf { m -> m >= 0 } }.normalizeReminders() +/** `s5,30` → [5 before start, 30 before due]; an empty string is an explicit "no reminder". */ +private fun parseLeads(stored: String): List = + stored.split(',').mapNotNull { ReminderLead.parse(it)?.takeIf { lead -> lead.minutes >= 0 } }.normalizeReminders() + +/** + * All-day defaults as whole days. Before 1.2 an all-day default could be hours + * before the all-day time; a lead that is not whole days now names a time of day. + */ +private fun List.inWholeDays(): List = + map { it.asTaskReminder().inWholeDays().asReminderLead() }.normalizeReminders() diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderPlanner.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderPlanner.kt index d341a72..7049d48 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderPlanner.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderPlanner.kt @@ -1,8 +1,9 @@ package de.jeanlucmakiola.agendula.data.reminders import de.jeanlucmakiola.agendula.data.prefs.Settings -import de.jeanlucmakiola.agendula.data.tasks.TaskReminder +import de.jeanlucmakiola.agendula.domain.AllDayReminder import de.jeanlucmakiola.agendula.domain.Task +import de.jeanlucmakiola.agendula.domain.TaskReminder import de.jeanlucmakiola.agendula.domain.calendarDate import java.time.LocalTime import java.time.ZoneId @@ -36,31 +37,53 @@ internal object ReminderPlanner { // One reminder per *occurrence* and lead: a recurring series yields a row // per occurrence, all sharing a taskId, so this is a Set rather than a // taskId-keyed Map. - .filter { !it.isClosed && it.due != null } + .filter { !it.isClosed && (it.due != null || it.start != null) } .flatMap { task -> - // Reminders set on the task itself win — every one of them, not just - // the one the editor shows; otherwise the task's list may override the - // global lead, or opt out entirely (override = null). All-day tasks - // have their own day-scale defaults. - val reminders = perTask[task.taskId].orEmpty().ifEmpty { - val leads = if (task.isAllDay) settings.allDayReminderLeadsFor(task.listId) - else settings.reminderLeadsFor(task.listId) - leads.map { TaskReminder(it) } - } - reminders.map { reminder -> - // A stored reminder says what it counts back from. Ours are always - // before due, but an imported dmfs alarm or another client's can be - // before *start*. - val anchor = if (reminder.fromStart) task.start ?: task.due!! else task.due!! + // Reminders set on the task itself win — every one of them; + // otherwise the task's list may override the global lead, or opt + // out entirely (override = null). All-day tasks have their own + // day-scale defaults. + // + // ⚠️ Present-but-empty is "none", not "the default": the store's + // answer for a task whose alarms another client turned off. A + // default here would keep ringing for what the user switched off. + // A default counts only from a date the task has; its own reminders + // stand in each date for the other when the task lacks it. + val own = perTask[task.taskId] + val reminders = own ?: settings.defaultRemindersFor(task.listId, task.isAllDay) + .filter { (if (it.fromStart) task.start else task.due) != null } + val created = task.created?.toEpochMilliseconds() + reminders.mapNotNull { reminder -> + val anchor = if (reminder.fromStart) task.start ?: task.due!! else task.due ?: task.start!! + val trigger = triggerAt( + anchor, + task.isAllDay, + reminder.minutesBefore, + settings.allDayReminderMinuteOfDay, + zone, + ) + // A default never rings for a moment before the task existed: + // that is a task saved after its default had passed, not a + // reminder missed while the device was off. + if (own == null && created != null && trigger < created) return@mapNotNull null + // Dismissed on another client already: firing it here within + // the missed-reminder grace would ring for something done with. + // + // ⚠️ An all-day reminder without a time of its own is compared + // from the start of its day, not from this device's time of + // day: other clients ring it at midnight, or at their own hour, + // so a dismissal at 07:00 must still silence the 09:00 this + // device would ring it at. + val earliest = if (task.isAllDay && reminder.allDay.minuteOfDay == null) { + triggerAt(anchor, true, reminder.minutesBefore, 0, zone) + } else { + trigger + } + val acknowledged = reminder.acknowledgedAt?.let { earliest <= it.toEpochMilliseconds() } == true + if (acknowledged) return@mapNotNull null ScheduledReminder( taskId = task.taskId, - triggerAt = triggerAt( - anchor, - task.isAllDay, - reminder.minutesBefore, - settings.allDayReminderMinuteOfDay, - zone, - ), + triggerAt = trigger, occurrenceStart = task.occurrenceStart?.toEpochMilliseconds() ?: ScheduledReminder.NO_OCCURRENCE, ) } @@ -80,8 +103,9 @@ internal object ReminderPlanner { * * ⚠️ An all-day anchor is UTC midnight of its date (see `AllDayTime.kt`), a * storage convention and not a moment — firing off it rang at 02:00 in - * Berlin and the evening before in New York. It is read as its calendar date - * at [allDayMinuteOfDay] local time instead, and the lead counts back from that. + * Berlin and the evening before in New York. It is read as its calendar + * date instead, and the lead as [TaskReminder.allDay]: whole days ring at + * [allDayMinuteOfDay] local time, anything else at the time it names. */ fun triggerAt( anchor: Instant, @@ -90,12 +114,11 @@ internal object ReminderPlanner { allDayMinuteOfDay: Int, zone: ZoneId, ): Long { - val base = if (allDay) { - val time = LocalTime.of(allDayMinuteOfDay / 60 % 24, allDayMinuteOfDay % 60) - anchor.calendarDate(allDay = true).atTime(time).atZone(zone).toInstant().toEpochMilli() - } else { - anchor.toEpochMilliseconds() - } - return base - leadMinutes.coerceAtLeast(0) * 60_000L + if (!allDay) return anchor.toEpochMilliseconds() - leadMinutes.coerceAtLeast(0) * 60_000L + val (days, minuteOfDay) = AllDayReminder.of(leadMinutes) + val minute = minuteOfDay ?: allDayMinuteOfDay + val time = LocalTime.of(minute / 60 % 24, minute % 60) + return anchor.calendarDate(allDay = true).minusDays(days.toLong()) + .atTime(time).atZone(zone).toInstant().toEpochMilli() } } 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 dc91251..958a0d5 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 @@ -1,5 +1,6 @@ package de.jeanlucmakiola.agendula.data.tasks +import de.jeanlucmakiola.agendula.domain.TaskReminder import android.content.ContentProviderOperation import android.content.ContentResolver import android.content.ContentUris @@ -148,9 +149,10 @@ class AndroidTasksDataSource @Inject constructor( // --- writes --------------------------------------------------------------- - override fun insertTask(form: TaskForm): Long { + override fun insertTask(form: TaskForm, reminders: List?): Long { val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id) - // One batch, so a refused tag leaves no task behind for a retry to duplicate. + // One batch, so a refused tag or alarm leaves no task behind for a retry + // to duplicate. val authority = authority() val ops = arrayListOf( ContentProviderOperation.newInsert(TasksContract.tasksUri(authority)) @@ -163,20 +165,35 @@ class AndroidTasksDataSource @Inject constructor( .withValueBackReference(Properties.TASK_ID, 0) .build() } + reminders.orEmpty().forEach { reminder -> + ops += ContentProviderOperation.newInsert(TasksContract.propertiesUri(authority)) + .withValues( + TaskWriteMapper.alarmValues(0, reminder.minutesBefore, reminder.fromStart) + .toContentValues().apply { remove(Properties.TASK_ID) }, + ) + .withValueBackReference(Properties.TASK_ID, 0) + .build() + } val results = runCatching { resolver.applyBatch(authority, ops) } .getOrElse { throw TaskWriteFailedException("insert task", it) } return results.firstOrNull()?.uri?.lastPathSegment?.toLongOrNull() ?: throw TaskWriteFailedException("insert task: no id") } - override fun updateTask(taskId: Long, form: TaskForm) { + override fun updateTask(taskId: Long, form: TaskForm, reminders: List?) { val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id) val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null) if (rows == 0) throw TaskWriteFailedException("update task $taskId") setCategories(taskId, form.tags) + writeReminders(taskId, reminders) } - override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) { + private fun writeReminders(taskId: Long, reminders: List?) { + if (reminders == null) return + setReminders(taskId, reminders) + } + + override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List?) { val occurrence = task(seriesId, occurrenceStart) val anchors = resolver.query( taskUri(authority(), seriesId), arrayOf(Tasks.DTSTART, Tasks.DUE), null, null, null, @@ -196,6 +213,7 @@ class AndroidTasksDataSource @Inject constructor( val rows = resolver.update(taskUri(authority(), seriesId), values.toContentValues(), null, null) if (rows == 0) throw TaskWriteFailedException("update series $seriesId") setCategories(seriesId, form.tags) + writeReminders(seriesId, reminders) } /** @@ -204,10 +222,10 @@ class AndroidTasksDataSource @Inject constructor( * task. Per-occurrence edits past the split point are dropped rather than * carried over — the provider offers no way to re-parent an exception. */ - override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm): Long { + override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List?): Long { val spec = seriesSpec(seriesId) ?: throw TaskWriteFailedException("split series $seriesId") if (spec.rrule == null || occurrenceStart <= spec.anchor) { - updateSeries(seriesId, occurrenceStart, form) + updateSeries(seriesId, occurrenceStart, form, reminders) return seriesId } // COUNT counts before EXDATE is applied (RFC 5545 §3.8.5.3). @@ -217,7 +235,7 @@ class AndroidTasksDataSource @Inject constructor( ).size endSeriesBefore(seriesId, spec, occurrenceStart) val rule = form.rrule?.let { if (it == spec.rrule) TaskFormWriter.remainingCount(it, spent) else it } - return insertTask(form.copy(rrule = rule, percentComplete = null)) + return insertTask(form.copy(rrule = rule, percentComplete = null), reminders) } override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) { @@ -283,7 +301,23 @@ class AndroidTasksDataSource @Inject constructor( resolver.delete(TasksContract.instanceUri(authority(), instanceId), null, null) } - override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) { + override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List?) { + if (reminders == null) return writeInstance(taskId, occurrenceStart, form) + // ⚠️ Written to the series, and then put back. Forking an occurrence + // copies the task's *current* properties onto the new override row, so + // setting the alarm beforehand is the only way to carry it across — and + // leaving it there would silently give every other occurrence in the + // series the reminder the user set on one of them. + val seriesReminders = alarmRows(taskId).map { it.second } + writeReminders(taskId, reminders) + try { + writeInstance(taskId, occurrenceStart, form) + } finally { + setReminders(taskId, seriesReminders) + } + } + + private fun writeInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) { val instanceId = instanceIdFor(taskId, occurrenceStart) ?: throw TaskWriteFailedException("update instance $taskId@$occurrenceStart: no such occurrence") val values = TaskWriteMapper.instanceValues(form, ZoneId.systemDefault().id) @@ -336,21 +370,6 @@ class AndroidTasksDataSource @Inject constructor( } } - override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) { - val uri = TasksContract.propertiesUri(authority()) - // Replace rather than update: the provider's AlarmHandler re-validates the - // whole row on every update, so a partial edit throws. Only the editable - // (last before-due) row goes — a start-relative or additional alarm - // another client wrote is not ours to drop. - alarmRows(taskId).lastOrNull { !it.second.fromStart }?.let { (propertyId, _) -> - resolver.delete(uri, "${Properties.PROPERTY_ID} = ?", arrayOf(propertyId.toString())) - } - if (minutesBeforeDue != null) { - resolver.insert(uri, TaskWriteMapper.alarmValues(taskId, minutesBeforeDue).toContentValues()) - ?: throw TaskWriteFailedException("set alarm for task $taskId") - } - } - override fun setReminders(taskId: Long, reminders: List) { val uri = TasksContract.propertiesUri(authority()) resolver.delete( @@ -367,9 +386,6 @@ class AndroidTasksDataSource @Inject constructor( override fun reminders(): Map> = readAlarms(null).groupBy({ it.first }) { it.third } - override fun alarms(): Map = - reminders().mapNotNull { (id, list) -> list.editable()?.let { id to it } }.toMap() - /** `(property_id, reminder)` for [taskId]'s alarm rows, in stored order. */ private fun alarmRows(taskId: Long): List> = readAlarms(taskId).map { it.second to it.third } 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 70f5fce..cbfd92a 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 @@ -1,5 +1,6 @@ package de.jeanlucmakiola.agendula.data.tasks +import de.jeanlucmakiola.agendula.domain.TaskReminder import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskForm @@ -34,10 +35,10 @@ class ModeRoutingTasksDataSource( override fun tasks(query: TaskQuery): List = active().tasks(query) override fun task(taskId: Long): Task? = active().task(taskId) override fun task(taskId: Long, occurrenceStart: Instant?): Task? = active().task(taskId, occurrenceStart) - override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) = - active().updateSeries(seriesId, occurrenceStart, form) - override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm): Long = - active().splitSeries(seriesId, occurrenceStart, form) + override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List?) = + active().updateSeries(seriesId, occurrenceStart, form, reminders) + override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List?): Long = + active().splitSeries(seriesId, occurrenceStart, form, reminders) override fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant) = active().deleteOccurrence(seriesId, occurrenceStart) override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) = @@ -45,14 +46,13 @@ class ModeRoutingTasksDataSource( override fun subtasks(parentTaskId: Long): List = active().subtasks(parentTaskId) override fun subtaskIds(parentTaskId: Long): List = active().subtaskIds(parentTaskId) override fun deleteWithSubtasks(taskId: Long) = active().deleteWithSubtasks(taskId) - override fun insertTask(form: TaskForm): Long = active().insertTask(form) - override fun updateTask(taskId: Long, form: TaskForm) = active().updateTask(taskId, form) + override fun insertTask(form: TaskForm, reminders: List?): Long = active().insertTask(form, reminders) + override fun updateTask(taskId: Long, form: TaskForm, reminders: List?) = + active().updateTask(taskId, form, reminders) - override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) = - active().updateInstance(taskId, occurrenceStart, form) + override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List?) = + active().updateInstance(taskId, occurrenceStart, form, reminders) - override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) = active().setAlarm(taskId, minutesBeforeDue) - override fun alarms(): Map = active().alarms() override fun reminders(): Map> = active().reminders() override fun setReminders(taskId: Long, reminders: List) = active().setReminders(taskId, reminders) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StoredReminders.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StoredReminders.kt new file mode 100644 index 0000000..f67ffcc --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StoredReminders.kt @@ -0,0 +1,101 @@ +package de.jeanlucmakiola.agendula.data.tasks + +import de.jeanlucmakiola.agendula.data.prefs.Settings +import de.jeanlucmakiola.agendula.data.reminders.ReminderPlanner +import de.jeanlucmakiola.agendula.domain.Task +import de.jeanlucmakiola.agendula.domain.TaskForm +import de.jeanlucmakiola.agendula.domain.TaskReminder +import java.time.ZoneId +import kotlin.time.Instant + +internal object StoredReminders { + + /** + * The reminders to store for [form], given the task as it was. + * + * A date the task gets in this write brings the list's or the app's + * default for it *as the task's own reminders*. The default is this + * device's setting, which nothing else can see; stored on the task, it + * travels with it to the server and fires on every client. The cost is + * tasks.org's: changing the default later does not reach the tasks that + * already have it. + * + * A task getting its first date takes the defaults only when the form + * names no reminder; one that already had a date takes those of the new + * date that no reminder of the form counts from. Otherwise the form is + * kept, so an edit that clears every reminder means "use the default + * here" as before. + * + * The form's reminders are already of its kind, timed or all-day — see + * [switched]. Reminders the editor [chose][TaskForm.remindersChosen] are + * kept as they are: it showed these defaults already. + */ + fun of(form: TaskForm, current: Task?, settings: Settings, now: Long, zone: ZoneId): List { + val wanted = form.reminders.map { it.lead }.distinct() + if (form.remindersChosen) return wanted + return withDefaults(form, current?.start, current?.due, settings, now, zone) + } + + /** [of] for a task whose dates were [start] and [due], whatever the form chose. */ + fun withDefaults( + form: TaskForm, + start: Instant?, + due: Instant?, + settings: Settings, + now: Long, + zone: ZoneId, + ): List { + val wanted = form.reminders.map { it.lead }.distinct() + val had = anchorsOf(start, due) + val gained = anchorsOf(form.start, form.due) - had + if (gained.isEmpty()) return wanted + if (had.isEmpty() || wanted.isEmpty()) return wanted.ifEmpty { defaultsFor(form, settings, now, zone) } + val named = wanted.map { it.fromStart }.toSet() + return wanted + defaultsFor(form, settings, now, zone).filter { it.fromStart in gained && it.fromStart !in named } + } + + /** + * [reminders] for [form] once it has switched between timed and all-day. + * Reminders that are exactly the old kind's default become the new kind's; + * the rest keep what still means something: whole days, rounded to them on + * an all-day task. + */ + fun switched( + reminders: List, + form: TaskForm, + settings: Settings, + now: Long, + zone: ZoneId, + ): List { + if (reminders.isEmpty()) return reminders + if (reminders.toSet() == settings.defaultRemindersFor(form.listId, !form.isAllDay).toSet()) { + return defaultsFor(form, settings, now, zone) + } + val kept = if (form.isAllDay) { + reminders.map { it.inWholeDays() } + } else { + reminders.filter { it.allDay.minuteOfDay == null } + } + return kept.distinct().ifEmpty { defaultsFor(form, settings, now, zone) } + } + + /** The dates a reminder can count from, as [TaskReminder.fromStart]. */ + private fun anchorsOf(start: Any?, due: Any?): Set = + setOfNotNull(true.takeIf { start != null }, false.takeIf { due != null }) + + /** + * The defaults [form] takes: those counting from a date it has. A one-off + * task leaves out what is already past — a start of "now" would ring at once. + */ + private fun defaultsFor(form: TaskForm, settings: Settings, now: Long, zone: ZoneId): List = + settings.defaultRemindersFor(form.listId, form.isAllDay).filter { reminder -> + val anchor = (if (reminder.fromStart) form.start else form.due) ?: return@filter false + form.rrule != null || ReminderPlanner.triggerAt( + anchor, + form.isAllDay, + reminder.minutesBefore, + settings.allDayReminderMinuteOfDay, + zone, + ) >= now + } +} 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 394c187..fe40f82 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 @@ -3,26 +3,9 @@ package de.jeanlucmakiola.agendula.data.tasks import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskList +import de.jeanlucmakiola.agendula.domain.TaskReminder import kotlin.time.Instant -/** - * A stored reminder: how long before, and what it counts back from. - * - * [fromStart] matters because both stores can hold a `START`-referenced alarm — - * the dmfs import preserves one, and an external provider's other clients write - * them — while Agendula's own UI only ever sets a before-due lead. Collapsing it - * to a number here is what silently fired those reminders off the wrong anchor. - */ -data class TaskReminder(val minutesBefore: Int, val fromStart: Boolean = false) - -/** - * The one reminder Agendula's single-reminder editor shows and writes: the last - * before-due one, which is where a write lands, so it stays the same one across - * saves. Anything else a task carries — a start-relative alarm, or the extra ones - * another client or the dmfs import brought — is left to ride along. - */ -fun List.editable(): TaskReminder? = lastOrNull { !it.fromStart } - /** What to fetch from the provider. Smart-list date logic is applied above this. */ data class TaskQuery( val listId: Long? = null, @@ -69,8 +52,15 @@ interface TasksDataSource { delete(taskId) } - fun insertTask(form: TaskForm): Long - fun updateTask(taskId: Long, form: TaskForm) + /** + * The write paths take the task's reminders in the same write as the task + * itself, so a sync can never upload one without the other. + * + * Each takes the task's whole set of [reminders], before-start and + * before-due alike, and `null` leaves the stored ones alone. + */ + fun insertTask(form: TaskForm, reminders: List? = null): Long + fun updateTask(taskId: Long, form: TaskForm, reminders: List? = null) /** * Update a single occurrence of a recurring task, addressed by the task row and @@ -83,27 +73,19 @@ interface TasksDataSource { * instance row id keeps this seam independent of any one store's row * numbering; External mode maps it back to an instance row itself. */ - fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) - /** - * Set (or clear, with `null`) the task's [editable] reminder lead, stored as an - * Alarm property row. Every other reminder on the task is left untouched. The - * provider never fires it — [de.jeanlucmakiola.agendula.data.reminders - * .ReminderScheduler] does — but persisting it here is what syncs the lead and - * shares it with other OpenTasks clients. - */ - fun setAlarm(taskId: Long, minutesBeforeDue: Int?) + fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List? = null) /** * Edit the whole series [seriesId] through its occurrence at [occurrenceStart]: * content from [form], and the series moved by however far that occurrence moved. */ - fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) + fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List? = null) /** * "This and following": end [seriesId] before [occurrenceStart] and continue * from there as a new series built from [form]. Returns the new series' id. */ - fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm): Long + fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List? = null): Long /** Delete one generated occurrence of [seriesId] (an `EXDATE`). */ fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant) @@ -112,14 +94,15 @@ interface TasksDataSource { fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) - /** Replace every reminder on [taskId] with [reminders], in order. */ + /** Make [taskId]'s reminders exactly [reminders]. A synced task is owed a PUT for it. */ fun setReminders(taskId: Long, reminders: List) - /** Every task's reminders, by task id, in stored order. One query, for the scheduler. */ - fun reminders(): Map> = alarms().mapValues { listOf(it.value) } - - /** Every task's [editable] reminder, by task id. */ - fun alarms(): Map + /** + * Every task's reminders, by task id, in stored order. One query, for the + * scheduler. A task missing from the map takes the default; one present + * with an empty list has none (see `TaskEntity.remindersOff`). + */ + fun reminders(): Map> /** * Every task in [listId] read from the **`tasks` table**, for export. Masters, diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepository.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepository.kt index b607cf2..347671e 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepository.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepository.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.agendula.data.tasks +import de.jeanlucmakiola.agendula.domain.TaskReminder import de.jeanlucmakiola.agendula.domain.SavedFilter import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskDetail @@ -82,13 +83,6 @@ interface TasksRepository { scope: RecurringScope = RecurringScope.AllOccurrences, ) - /** - * The per-task reminder lead in minutes before due, or `null` if the task has - * none (in which case the list's / global setting applies). Read when the edit - * form loads so saving can't silently drop it. - */ - suspend fun reminderFor(taskId: Long): Int? - /** Every reminder stored on the task, in order — including start-relative ones. */ suspend fun remindersFor(taskId: Long): List 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 057e6c0..5f77c29 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 @@ -1,5 +1,6 @@ package de.jeanlucmakiola.agendula.data.tasks +import de.jeanlucmakiola.agendula.domain.TaskReminder import de.jeanlucmakiola.agendula.data.di.IoDispatcher import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.floret.time.DayWindow @@ -21,6 +22,7 @@ import kotlinx.coroutines.channels.consumeEach import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn @@ -109,11 +111,18 @@ class TasksRepositoryImpl @Inject constructor( override suspend fun createTask(form: TaskForm): Long = withContext(io) { - val id = dataSource.insertTask(form) - writeReminders(id, form) - id + dataSource.insertTask(form, storedRemindersFor(form, current = null)) } + private suspend fun storedRemindersFor(form: TaskForm, current: Task?): List = + StoredReminders.of( + form, + current, + settingsPrefs.settings.first(), + Clock.System.now().toEpochMilliseconds(), + ZoneId.systemDefault(), + ) + override suspend fun duplicateTask(taskId: Long, occurrenceStart: Instant?): Long = withContext(io) { val task = checkNotNull(dataSource.task(taskId, occurrenceStart)) { "Task $taskId is gone" } @@ -121,6 +130,9 @@ class TasksRepositoryImpl @Inject constructor( } private fun copyOf(task: Task, parentId: Long?, depth: Int): Long { + // In the same write as the copy: inserted first and given its reminders + // after, a sync landing in between uploads the copy without them. + val reminders = dataSource.reminders()[task.taskId].orEmpty().map { it.lead } val id = dataSource.insertTask( TaskForm( title = task.title, @@ -135,9 +147,8 @@ class TasksRepositoryImpl @Inject constructor( url = task.url, tags = task.tags, ), + reminders, ) - val reminders = dataSource.reminders()[task.taskId].orEmpty() - if (reminders.isNotEmpty()) dataSource.setReminders(id, reminders) if (depth < MAX_COPY_DEPTH) { SeriesCollapse.visible(dataSource.subtasks(task.taskId)) .filter { it.taskId != task.taskId && it.status != TaskStatus.CANCELLED } @@ -146,9 +157,6 @@ class TasksRepositoryImpl @Inject constructor( return id } - override suspend fun reminderFor(taskId: Long): Int? = - withContext(io) { runCatching { dataSource.alarms()[taskId]?.minutesBefore }.getOrNull() } - override suspend fun remindersFor(taskId: Long): List = withContext(io) { runCatching { dataSource.reminders()[taskId].orEmpty() }.getOrDefault(emptyList()) } @@ -172,18 +180,17 @@ class TasksRepositoryImpl @Inject constructor( val seen = current?.lastModified if (seen != null && seen != expectedLastModified) throw TaskConflictException(taskId) } + val reminders = storedRemindersFor(form, current) val seriesId = current?.seriesId val occurrence = current?.occurrenceStart if (seriesId != null && occurrence != null) { when (scope) { RecurringScope.AllOccurrences -> { - writeReminders(seriesId, form) - dataSource.updateSeries(seriesId, occurrence, form) + dataSource.updateSeries(seriesId, occurrence, form, reminders) return@withContext } RecurringScope.ThisAndFollowing -> { - val created = dataSource.splitSeries(seriesId, occurrence, form) - writeReminders(created, form) + dataSource.splitSeries(seriesId, occurrence, form, reminders) return@withContext } RecurringScope.ThisOccurrence -> Unit @@ -191,31 +198,11 @@ class TasksRepositoryImpl @Inject constructor( } // A recurring task's start/due are one occurrence's resolved times, so // writing them back to the task row would re-anchor the whole series. - // Going through the occurrence forks an override instead. + // Going through the occurrence forks an override instead, which takes + // the reminders on its own row and leaves the series' alone. val instance = current?.takeIf { it.isRecurring }?.occurrenceStart - if (instance == null) { - writeReminders(taskId, form) - dataSource.updateTask(taskId, form) - return@withContext - } - // ⚠️ Written to the series, and then put back. Forking an occurrence - // copies the task's *current* properties onto the new override row, - // so setting the alarm beforehand is the only way to carry it - // across — and leaving it there would silently give every other - // occurrence in the series the reminder the user set on one of them. - val seriesReminders = dataSource.reminders()[taskId].orEmpty() - writeReminders(taskId, form) - try { - dataSource.updateInstance(taskId, instance, form) - } finally { - dataSource.setReminders(taskId, seriesReminders) - } - } - - /** The form's before-due reminders; a start-relative one from elsewhere is kept as it was. */ - private fun writeReminders(taskId: Long, form: TaskForm) { - val kept = dataSource.reminders()[taskId].orEmpty().filter { it.fromStart } - dataSource.setReminders(taskId, kept + form.reminders.distinct().sorted().map { TaskReminder(it) }) + if (instance == null) dataSource.updateTask(taskId, form, reminders) + else dataSource.updateInstance(taskId, instance, form, reminders) } override suspend fun setCancelled(taskId: Long, occurrenceStart: Instant?, cancelled: Boolean) = diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ical/AlarmCodec.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ical/AlarmCodec.kt new file mode 100644 index 0000000..b31dbbc --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ical/AlarmCodec.kt @@ -0,0 +1,228 @@ +package de.jeanlucmakiola.agendula.data.tasks.ical + +import de.jeanlucmakiola.agendula.data.reminders.ReminderPlanner +import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference +import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity +import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity +import de.jeanlucmakiola.agendula.domain.ical.ICalComponent +import de.jeanlucmakiola.agendula.domain.ical.ICalParam +import de.jeanlucmakiola.agendula.domain.ical.ICalParser +import de.jeanlucmakiola.agendula.domain.ical.ICalProperty +import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer +import de.jeanlucmakiola.agendula.domain.ical.ICalValues +import de.jeanlucmakiola.agendula.domain.DAY_MINUTES +import de.jeanlucmakiola.agendula.domain.MIN_ALL_DAY_LEAD +import kotlin.math.roundToLong +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant + +/** + * `VALARM` ↔ [TaskAlarmEntity]. + * + * ## What is claimed + * + * Only an alarm Agendula can fire exactly as written becomes a reminder row. + * Everything else stays in the task's residue and round-trips untouched — it + * still fires in the client that wrote it, just not here: + * + * - `ACTION` other than `DISPLAY`/`AUDIO`; + * - Apple's default alarms (`X-APPLE-DEFAULT-ALARM:TRUE`). They are that Mac's + * preference, not task data: claimed, they would come back to the Mac as an + * explicit alarm without the flag after the first edit here; + * - location alarms (`X-APPLE-PROXIMITY`, RFC 9074 `PROXIMITY`, a nested + * component) and Apple's `19760401T005545Z` sentinel trigger they carry; + * - repeating alarms (`REPEAT`/`DURATION`) and RFC 9074 snoozes (`RELATED-TO`); + * - a trigger *after* its anchor — on an all-day task, after the end of its + * day. The planner coerces a negative lead to 0, so it would fire at the + * wrong time; + * - an absolute trigger on a recurring task, or on one with no `DUE` to count + * back from. + * + * ## What "unchanged" means + * + * A claimed row keeps the `VALARM` it was read from in [TaskAlarmEntity.raw]. + * On write, the row is "unchanged" when that original still [resolve]s to the + * row's lead against the task *as it is now* — so `-PT15M` and `-P0DT0H15M0S` + * are the same, a relative alarm survives a due-date edit, and an absolute one + * stops matching once `DUE` moves. An unchanged row is re-emitted verbatim; a + * changed one is authored fresh under its existing uid. + * + * ## Anchors + * + * `RELATED=END` counts from `DUE`. `RELATED=START` (the default) counts from + * `DTSTART` — except when `DTSTART` equals `DUE`, or when there is no + * `DTSTART` but there is a `DUE`. Apple writes the latter shape (against §3.8.6.3, + * which wants `DTSTART` present). + * + * ## All-day tasks + * + * A relative trigger counts from the date's midnight, so the lead is the + * reminder as [de.jeanlucmakiola.agendula.domain.TaskReminder.allDay] reads it: + * `-P1D` is a day before at this device's all-day time, which this mapper cannot + * see, and `PT9H` is 09:00 on the day. An absolute trigger has no zone to read + * a time of day in, so it rounds to the nearest whole day. + */ +object AlarmCodec { + + /** Apple's placeholder trigger for location alarms. Never a real time. */ + private const val APPLE_SENTINEL = "19760401T005545Z" + + /** What a `VALARM` means to the scheduler. */ + data class Lead(val minutesBefore: Int, val reference: AlarmReference) + + /** A `VALARM` as a reminder row — `taskId` is the caller's — or null if it is not ours to claim. */ + fun fromVAlarm(alarm: ICalComponent, task: TaskEntity): TaskAlarmEntity? { + val lead = resolve(alarm, task) ?: return null + val raw = ICalSerializer.serialize(alarm) + // Too large to keep verbatim means it could not be re-emitted, and an + // alarm that is authored fresh on every write loses what made it large. + if (raw.toByteArray(Charsets.UTF_8).size > VTodoMapper.MAX_RESIDUE_BYTES) return null + val uid = (alarm.property("X-WR-ALARMUID") ?: alarm.property("UID"))?.value?.trim()?.takeIf { it.isNotEmpty() } + val base = TaskAlarmEntity( + taskId = 0, + minutesBefore = lead.minutesBefore, + reference = lead.reference, + raw = raw, + ) + return if (uid == null) base else base.copy(uid = uid) + } + + /** The lead [alarm] stands for on [task], or null when it is not claimable. */ + fun resolve(alarm: ICalComponent, task: TaskEntity): Lead? { + val action = alarm.property("ACTION")?.value?.trim()?.uppercase() + if (action != "DISPLAY" && action != "AUDIO") return null + if (alarm.property("X-APPLE-DEFAULT-ALARM")?.value?.trim().equals("TRUE", ignoreCase = true)) return null + val excluded = listOf("X-APPLE-PROXIMITY", "PROXIMITY", "REPEAT", "DURATION", "RELATED-TO") + if (excluded.any { alarm.property(it) != null }) return null + if (alarm.components.isNotEmpty()) return null + + val trigger = alarm.properties("TRIGGER").singleOrNull() ?: return null + val value = trigger.value.trim() + if (value.startsWith(APPLE_SENTINEL)) return null + + val absolute = trigger.param("VALUE").equals("DATE-TIME", ignoreCase = true) || + (!value.startsWith("P", ignoreCase = true) && !value.startsWith("-") && !value.startsWith("+")) + return if (absolute) absoluteLead(trigger, task) else relativeLead(trigger, task) + } + + private fun relativeLead(trigger: ICalProperty, task: TaskEntity): Lead? { + val seconds = ICalValues.parseDuration(trigger.value) ?: return null + val reference = when { + trigger.param("RELATED").equals("END", ignoreCase = true) -> + if (task.due != null) AlarmReference.DUE else return null + task.dtstart != null && task.dtstart != task.due -> AlarmReference.START + task.due != null -> AlarmReference.DUE + else -> return null + } + val before = -seconds + // A lead in seconds is not one the planner can reproduce. + if (before % 60 != 0L) return null + val minutes = before / 60 + val latest = if (task.isAllDay) MIN_ALL_DAY_LEAD.toLong() else 0L + return minutes.takeIf { it in latest..Int.MAX_VALUE }?.let { Lead(it.toInt(), reference) } + } + + private fun absoluteLead(trigger: ICalProperty, task: TaskEntity): Lead? { + if (task.rrule != null || task.rdate != null) return null + val due = task.due ?: return null + val at = (ICalValues.parseTime(trigger) as? ICalValues.TimeValue.Timed) + ?.takeIf { it.tzid == null } // §3.8.6.3: an absolute trigger MUST be UTC + ?.instant ?: return null + val before = (due - at).inWholeSeconds + val minutes = if (task.isAllDay) { + // An all-day DUE is stored as UTC midnight of its date, which is + // the calendar date the trigger counts back from. + roundToDays(before) + } else { + Math.floorDiv(before, 60L) + } + return minutes.toIntOrNull()?.let { Lead(it, AlarmReference.DUE) } + } + + /** + * [read] as builds before 1.2 stored it on an all-day task: rounded to whole + * days. Such a row keeps the other client's time of day instead of overwriting it. + */ + private fun readAsWholeDays(read: Lead, task: TaskEntity): Lead? = + if (task.isAllDay && read.minutesBefore % DAY_MINUTES != 0) { + read.copy(minutesBefore = roundToDays(read.minutesBefore * 60L).coerceAtLeast(0).toInt()) + } else { + null + } + + /** Nearest whole day, in minutes. */ + private fun roundToDays(seconds: Long): Long = + (seconds / 86_400.0).roundToLong() * DAY_MINUTES + + /** Negative is after the anchor, which is not claimed. */ + private fun Long.toIntOrNull(): Int? = takeIf { it in 0..Int.MAX_VALUE }?.toInt() + + /** + * [row] as a `VALARM` on [task], or null when it cannot be written: its + * anchor is missing. That row stays on the device — the planner ignores it + * too — because `RELATED=END` without `DUE` is a resource the server + * refuses ([VTodoMapper.validate]). + * + * ⚠️ A row whose anchor the task has lost is written against the other + * date, which is what the planner fires it from. Leaving it out would drop + * it from every other client, and the next download would then take it off + * this device too. + * + * [now] marks an authored alarm `ACKNOWLEDGED` once it is past even the + * planner's missed-reminder grace, so a client that honours RFC 9074 does + * not greet it as a missed alarm. Not sooner: the stamp comes back on the + * next download and would silence the late reminder this device still owes. + */ + fun toVAlarm(row: TaskAlarmEntity, task: TaskEntity, now: Instant): ICalComponent? { + val reference = when { + row.reference == AlarmReference.START && task.dtstart == null -> AlarmReference.DUE + row.reference == AlarmReference.DUE && task.due == null -> AlarmReference.START + else -> row.reference + } + val lead = Lead(row.minutesBefore, reference) + row.raw?.let(::parse)?.let { original -> + val read = resolve(original, task) + if (read == lead || read != null && readAsWholeDays(read, task) == lead) return original + } + + val anchor = when (reference) { + AlarmReference.DUE -> task.due + AlarmReference.START -> task.dtstart + } ?: return null + val related = if (reference == AlarmReference.DUE) "END" else "START" + val minutes = row.minutesBefore.coerceAtLeast(if (task.isAllDay) MIN_ALL_DAY_LEAD else 0).toLong() + val seconds = -minutes * 60 + + val properties = mutableListOf( + ICalProperty("UID", value = row.uid), + ICalProperty("X-WR-ALARMUID", value = row.uid), + ICalProperty("ACTION", value = "DISPLAY"), + ICalProperty("DESCRIPTION", value = ICalValues.escapeText(row.message ?: "Reminder")), + ICalProperty("TRIGGER", listOf(ICalParam("RELATED", related)), ICalValues.formatDuration(seconds)), + ) + val recurring = task.rrule != null || task.rdate != null + if (!recurring && anchor + seconds.seconds + ReminderPlanner.MISSED_GRACE_MS.milliseconds < now) { + properties += ICalProperty("ACKNOWLEDGED", value = ICalValues.formatDateTime(now, null)) + } + return ICalComponent("VALARM", properties) + } + + /** + * What [row] reads back as once it has been to the server and back — not + * always the row itself, because the wire is coarser: a lead past the end + * of its anchor is clamped, and a before-start row on a task without a + * start goes out before-due. A download that brings back exactly this has not changed the + * reminder, and the row's own lead must be kept rather than taken from it. + * + * ⚠️ As authored from the row, not re-emitted from [TaskAlarmEntity.raw]: + * a row rounded to whole days before 1.2 re-emits a time of day it does + * not hold, and would keep its rounded lead against the very alarm that + * carries it. + */ + fun wireLead(row: TaskAlarmEntity, task: TaskEntity): Lead? = + toVAlarm(row.copy(raw = null), task, Instant.DISTANT_PAST)?.let { resolve(it, task) } + + private fun parse(raw: String): ICalComponent? = + runCatching { ICalParser.parse(raw) }.getOrNull()?.takeIf { it.name.equals("VALARM", ignoreCase = true) } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ical/VTodoMapper.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ical/VTodoMapper.kt index 084d84b..313bf7b 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ical/VTodoMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ical/VTodoMapper.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.agendula.data.tasks.ical +import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE import de.jeanlucmakiola.agendula.domain.TaskStatus @@ -52,10 +53,11 @@ import kotlin.time.Instant * * ## Alarms * - * `VALARM`s round-trip in the residue and are never authored here. Local - * reminders live in `task_alarms` and are never serialised. The two stores are - * disjoint, so neither can destroy the other. Merging them is a later - * decision, not a silent one. + * A `VALARM` [AlarmCodec] can fire exactly as written is claimed into + * [Mapped.alarms] — rows for `task_alarms`, carrying the original verbatim — + * and [write] authors the task's rows back. Any other `VALARM` (Apple's default + * and location alarms, repeating ones, snoozes) stays in the residue and is + * re-emitted untouched after the authored ones. */ object VTodoMapper { @@ -95,6 +97,8 @@ object VTodoMapper { val uidWasMissing: Boolean, /** Residue that exceeded [MAX_RESIDUE_BYTES] and had to be dropped. */ val droppedResidue: Boolean, + /** The claimed `VALARM`s, `taskId` unset. Not counted against the residue cap. */ + val alarms: List = emptyList(), ) // ---------------------------------------------------------------- read @@ -263,8 +267,18 @@ object VTodoMapper { .filterIndexed { index, _ -> index !in claimed } .filterNot { it.name.equals("DTSTAMP", ignoreCase = true) } + // Claimed against the entity as read, so the anchors are the ones the + // rows will be written back against. + val alarms = mutableListOf() + val residueComponents = vtodo.components.filter { component -> + if (!component.name.equals("VALARM", ignoreCase = true)) return@filter true + val row = AlarmCodec.fromVAlarm(component, entity) ?: return@filter true + alarms += row + false + } + val residueText = ICalSerializer.serializeProperties(residueProperties) + - ICalSerializer.serializeAll(vtodo.components) + ICalSerializer.serializeAll(residueComponents) val tooLarge = residueText.toByteArray(Charsets.UTF_8).size > MAX_RESIDUE_BYTES return Mapped( @@ -274,6 +288,7 @@ object VTodoMapper { parentUid = parentUid, uidWasMissing = uid.isEmpty(), droppedResidue = tooLarge, + alarms = alarms, ) } @@ -283,12 +298,14 @@ object VTodoMapper { * Serialises [entity] back to a VTODO. * * [parentUid] is the parent's `UID`, which the entity holds only as a row id. - * [now] is the `DTSTAMP`. + * [now] is the `DTSTAMP`. [alarms] are the task's reminder rows; one whose + * anchor the task no longer has is left out (see [AlarmCodec.toVAlarm]). */ fun write( entity: TaskEntity, parentUid: String? = null, now: Instant = Clock.System.now(), + alarms: List = emptyList(), ): ICalComponent { val residue = parseResidue(entity.unknownProperties) val residueCategories = normalizeTags(residue.properties("CATEGORIES").flatMap(::categoryValues)) @@ -349,7 +366,7 @@ object VTodoMapper { return ICalComponent( name = "VTODO", properties = properties + keptResidue, - components = residue.components, + components = alarms.mapNotNull { AlarmCodec.toVAlarm(it, entity, now) } + residue.components, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/AlarmLift.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/AlarmLift.kt new file mode 100644 index 0000000..a43ce8f --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/AlarmLift.kt @@ -0,0 +1,101 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import de.jeanlucmakiola.agendula.domain.ical.ICalComponent +import de.jeanlucmakiola.agendula.domain.ical.ICalParser +import de.jeanlucmakiola.agendula.domain.ical.ICalProperty +import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer +import de.jeanlucmakiola.agendula.domain.ical.ICalValues +import kotlin.math.roundToLong +import kotlin.time.Instant + +/** + * Lifts claimable `VALARM`s out of a residue written before v3 synced them. + * + * ⚠️ Frozen with [TasksDatabase.MIGRATION_2_3]: a copy of `AlarmCodec`'s claim + * rule as it stood then, not a call into it, so a later change to what the + * codec claims cannot change what this migration produced for an earlier + * upgrader. + */ +internal object AlarmLift { + + /** The task columns the claim rule reads. */ + data class Task( + val dtstart: Instant?, + val due: Instant?, + val isAllDay: Boolean, + val recurring: Boolean, + ) + + data class Alarm(val minutesBefore: Int, val reference: AlarmReference, val uid: String?, val raw: String) + + /** The lifted alarms and the residue without them, or `null` residue when nothing is left. */ + data class Result(val alarms: List, val residue: String?) + + private const val DAY_MINUTES = 24 * 60 + private const val MAX_RAW_BYTES = 25 * 1024 + + fun lift(residue: String?, task: Task): Result { + val component = parse(residue) ?: return Result(emptyList(), residue) + val alarms = mutableListOf() + val kept = component.components.filter { sub -> + if (!sub.name.equals("VALARM", ignoreCase = true)) return@filter true + val alarm = claim(sub, task) ?: return@filter true + alarms += alarm + false + } + if (alarms.isEmpty()) return Result(emptyList(), residue) + val rest = ICalSerializer.serializeProperties(component.properties) + ICalSerializer.serializeAll(kept) + return Result(alarms, rest.ifEmpty { null }) + } + + private fun claim(alarm: ICalComponent, task: Task): Alarm? { + val action = alarm.property("ACTION")?.value?.trim()?.uppercase() + if (action != "DISPLAY" && action != "AUDIO") return null + if (alarm.property("X-APPLE-DEFAULT-ALARM")?.value?.trim().equals("TRUE", ignoreCase = true)) return null + if (listOf("X-APPLE-PROXIMITY", "PROXIMITY", "REPEAT", "DURATION", "RELATED-TO").any { alarm.property(it) != null }) { + return null + } + if (alarm.components.isNotEmpty()) return null + val trigger = alarm.properties("TRIGGER").singleOrNull() ?: return null + val value = trigger.value.trim() + if (value.startsWith("19760401T005545Z")) return null + val absolute = trigger.param("VALUE").equals("DATE-TIME", ignoreCase = true) || + (!value.startsWith("P", ignoreCase = true) && !value.startsWith("-") && !value.startsWith("+")) + val (minutes, reference) = (if (absolute) absolute(trigger, task) else relative(trigger, task)) ?: return null + val raw = ICalSerializer.serialize(alarm) + if (raw.toByteArray(Charsets.UTF_8).size > MAX_RAW_BYTES) return null + val uid = (alarm.property("X-WR-ALARMUID") ?: alarm.property("UID"))?.value?.trim()?.takeIf { it.isNotEmpty() } + return Alarm(minutes, reference, uid, raw) + } + + private fun relative(trigger: ICalProperty, task: Task): Pair? { + val seconds = ICalValues.parseDuration(trigger.value) ?: return null + val reference = when { + trigger.param("RELATED").equals("END", ignoreCase = true) -> + if (task.due != null) AlarmReference.DUE else return null + task.dtstart != null && task.dtstart != task.due -> AlarmReference.START + task.due != null -> AlarmReference.DUE + else -> return null + } + val before = -seconds + val minutes = if (task.isAllDay) days(before) else if (before % 60 != 0L) return null else before / 60 + return minutes.takeIf { it in 0..Int.MAX_VALUE }?.let { it.toInt() to reference } + } + + private fun absolute(trigger: ICalProperty, task: Task): Pair? { + if (task.recurring) return null + val due = task.due ?: return null + val at = (ICalValues.parseTime(trigger) as? ICalValues.TimeValue.Timed) + ?.takeIf { it.tzid == null }?.instant ?: return null + val before = (due - at).inWholeSeconds + val minutes = if (task.isAllDay) days(before) else Math.floorDiv(before, 60L) + return minutes.takeIf { it in 0..Int.MAX_VALUE }?.let { it.toInt() to AlarmReference.DUE } + } + + private fun days(seconds: Long): Long = (seconds / 86_400.0).roundToLong() * DAY_MINUTES + + private fun parse(text: String?): ICalComponent? { + if (text.isNullOrEmpty()) return null + return runCatching { ICalParser.parse("BEGIN:VTODO\r\n$text\r\nEND:VTODO\r\n") }.getOrNull() + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Entities.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Entities.kt index 513efc1..cad74ee 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Entities.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Entities.kt @@ -8,6 +8,7 @@ import androidx.room.PrimaryKey import androidx.room.TypeConverters import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE import de.jeanlucmakiola.agendula.domain.TaskStatus +import java.util.UUID import kotlin.time.Instant /** @@ -199,12 +200,31 @@ data class TaskEntity( * verbatim on write so a round-trip cannot silently lose a field. */ @ColumnInfo(name = "unknown_properties") val unknownProperties: String? = null, + + // reminders + /** + * Another client removed this task's last alarm, so it has none — not "use + * the default". A task without reminder rows normally takes the list's or + * the app's default, which is right for one that never had any (a client + * that does not write alarms), and wrong for one whose alarms were turned + * off elsewhere: it would keep ringing here. Set by a download that takes + * the last row away; cleared as soon as the task has a reminder again. + */ + @ColumnInfo(name = "reminders_off", defaultValue = "0") val remindersOff: Boolean = false, ) /** What [TaskAlarmEntity.minutesBefore] counts back from. */ enum class AlarmReference { DUE, START } -/** A reminder lead on a task. Positive [minutesBefore] is *before* [reference]. */ +/** + * A reminder lead on a task. Positive [minutesBefore] is *before* [reference]. + * + * Synced as a `VALARM` (see `AlarmCodec`). [uid] is the alarm's identity on the + * wire — `X-WR-ALARMUID` / RFC 9074 `UID` — minted when the row is created, so + * every upload of the same alarm names it the same way. [raw] is the `VALARM` + * this row was read from, re-emitted verbatim while the row still means what + * it did, so another client's `ACKNOWLEDGED`, `X-*` and trigger form survive. + */ @Entity( tableName = "task_alarms", foreignKeys = [ @@ -224,4 +244,6 @@ data class TaskAlarmEntity( @ColumnInfo(name = "minutes_before") val minutesBefore: Int, @ColumnInfo(name = "reference", defaultValue = "DUE") val reference: AlarmReference = AlarmReference.DUE, @ColumnInfo(name = "message") val message: String? = null, + @ColumnInfo(name = "uid", defaultValue = "") val uid: String = UUID.randomUUID().toString(), + @ColumnInfo(name = "raw") val raw: String? = null, ) 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 30975b3..f97b16f 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 @@ -2,8 +2,8 @@ package de.jeanlucmakiola.agendula.data.tasks.room import androidx.room.InvalidationTracker import de.jeanlucmakiola.agendula.data.tasks.TaskQuery -import de.jeanlucmakiola.agendula.data.tasks.TaskReminder -import de.jeanlucmakiola.agendula.data.tasks.editable +import de.jeanlucmakiola.agendula.domain.TaskReminder +import de.jeanlucmakiola.agendula.domain.TaskReminderOrder import de.jeanlucmakiola.agendula.data.tasks.TaskWriteFailedException import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource import de.jeanlucmakiola.agendula.domain.Task @@ -77,6 +77,9 @@ internal fun seriesOccurrences(master: TaskEntity, overrides: List, internal val OBSERVED_TABLES = arrayOf("tasks", "task_lists", "task_alarms", "accounts") +/** Distinct alarm texts whose `ACKNOWLEDGED` is remembered between scheduler passes. */ +private const val ACK_CACHE_SIZE = 512 + /** * [TasksDataSource] over Agendula's own Room store. * @@ -154,16 +157,23 @@ class RoomTasksDataSource @Inject constructor( override fun exportTasks(listId: Long): List = tasks.exportTasks(listId).map(RoomTaskMapper::exportTask) - override fun reminders(): Map> = - alarms.all().groupBy({ it.taskId }) { - TaskReminder( - minutesBefore = it.minutesBefore, - fromStart = it.reference == AlarmReference.START, - ) - } + override fun reminders(): Map> { + val stored = alarms.all().groupBy({ it.taskId }) { it.asReminder().copy(acknowledgedAt = acknowledged(it)) } + // Turned off elsewhere: present and empty, so nothing falls back to a default. + return stored + alarms.remindersOff().filterNot(stored::containsKey).associateWith { emptyList() } + } - override fun alarms(): Map = - reminders().mapNotNull { (id, list) -> list.editable()?.let { id to it } }.toMap() + /** `raw` → its `ACKNOWLEDGED`, so a scheduler pass does not re-parse every alarm. */ + private val acknowledgements = object : LinkedHashMap(64, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry) = size > ACK_CACHE_SIZE + } + + private fun acknowledged(row: TaskAlarmEntity): Instant? { + val raw = row.raw ?: return null + return synchronized(acknowledgements) { + if (raw in acknowledgements) acknowledgements[raw] else row.acknowledgedAt().also { acknowledgements[raw] = it } + } + } /** * Every occurrence of [row] inside the expansion window, with any @@ -200,20 +210,42 @@ class RoomTasksDataSource @Inject constructor( // --- writes --------------------------------------------------------------- - override fun insertTask(form: TaskForm): Long { + override fun insertTask(form: TaskForm, reminders: List?): Long { if (lists.exists(form.listId) == 0) throw TaskWriteFailedException("insert task: no list ${form.listId}") val entity = TaskFormWriter.newTask(form, uid = UUID.randomUUID().toString(), now = clock.now(), tzId = zone()) - return tasks.insert(TaskFormWriter.withRule(entity, form.rrule)).also { writes.onWritten(setOf(form.listId)) } + var id = 0L + tx { + id = tasks.insert(TaskFormWriter.withRule(entity, form.rrule)) + reminders?.let { alarms.syncForTask(id, it) } + writes.onWritten(setOf(form.listId)) + } + return id } - override fun updateTask(taskId: Long, form: TaskForm) { + /** + * [reminders] as [taskId]'s own, inside the caller's transaction. + * + * ⚠️ Part of the task write rather than a second call after it. Once + * reminders sync, a sync landing between the two would upload the task + * without them, or with the ones it is about to lose. + */ + private fun writeReminders(taskId: Long, reminders: List?) { + if (reminders == null) return + alarms.syncForTask(taskId, reminders) + } + + private fun storedReminders(taskId: Long): List = + alarms.forTask(taskId).map { it.asReminder() }.sortedWith(TaskReminderOrder) + + 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) if (current.masterId == null && edited.listId != current.listId) { if (lists.exists(edited.listId) == 0) throw TaskWriteFailedException("move task: no list ${edited.listId}") - database.runInTransaction { move(current, TaskFormWriter.withRule(edited, form.rrule)) } + move(current, TaskFormWriter.withRule(edited, form.rrule)) writes.onWritten(setOf(current.listId, edited.listId)) - return + return@tx } // ⚠️ An override's list and parent are the master's, the rule // [updateInstance] states and this path has to keep. An overridden @@ -247,8 +279,23 @@ class RoomTasksDataSource @Inject constructor( plan.tombstone?.let { tasks.insert(it) } } - override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) = tx { + override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List?) = tx { val master = seriesMaster(seriesId) + // ⚠️ The overrides too, where they still carry the series' reminders. + // A fork copies them as a snapshot — completing one occurrence is + // enough — and each override uploads its own VALARMs, so leaving them + // would keep ringing those occurrences at the old lead everywhere. One + // whose reminders were set for that occurrence alone keeps them. + if (reminders != null) { + val before = storedReminders(master.id) + tasks.overrides(master.id) + .filter { storedReminders(it.id) == before } + .forEach { + writeReminders(it.id, reminders) + if (form.remindersChosen && reminders.isEmpty()) alarms.setRemindersOff(it.id, true) + } + } + writeReminders(master.id, reminders) val (start, due) = resolvedTimes(master, occurrenceStart) val edited = TaskFormWriter.seriesEdit(master, start, due, form, clock.now(), zone()) // A moved or re-ruled series no longer generates the anchors its @@ -264,14 +311,14 @@ class RoomTasksDataSource @Inject constructor( writes.onWritten(setOf(master.listId, edited.listId)) } - override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm): Long { + override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List?): Long { var created = seriesId tx { val master = seriesMaster(seriesId) val spec = master.recurrenceSpec() val anchor = master.recurrenceAnchor if (spec == null || anchor == null || occurrenceStart <= anchor) { - updateSeries(seriesId, occurrenceStart, form) + updateSeries(seriesId, occurrenceStart, form, reminders) return@tx } val now = clock.now() @@ -314,7 +361,9 @@ class RoomTasksDataSource @Inject constructor( else TaskFormWriter.shiftValues(laterExceptions, delta, master.timezone ?: zone()) val fresh = TaskFormWriter.withRule(applied, rule).let { if (it.rrule != null) it.copy(exdate = exceptions) else it } val newId = tasks.insert(fresh) - alarms.forTask(master.id).forEach { alarms.insert(it.copy(id = 0, taskId = newId)) } + // A new UID, so a new resource: the alarms are copies, not the same ones. + alarms.copyToTask(master.id, newId, sameResource = false) + writeReminders(newId, reminders) val unmoved = fresh.dtstart == start && fresh.due == due && sameRule tasks.overrides(master.id) @@ -378,7 +427,7 @@ class RoomTasksDataSource @Inject constructor( * which is the model least compatible with sync; the override shares its * master's UID, which is exactly what makes it an override. */ - override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) { + override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List?) = tx { val master = tasks.entity(taskId) ?: throw TaskWriteFailedException("update instance $taskId") val now = clock.now() val existing = tasks.override(taskId, occurrenceStart) @@ -387,8 +436,9 @@ class RoomTasksDataSource @Inject constructor( // master's, whatever the form was carrying. val edited = TaskFormWriter.apply(existing, form, now, zone()) tasks.update(edited.copy(listId = master.listId, parentId = master.parentId)) + writeReminders(existing.id, reminders) writes.onWritten(setOf(master.listId)) - return + return@tx } val (start, due) = occurrenceTimes(master, occurrenceStart) val fork = TaskFormWriter.apply( @@ -400,28 +450,19 @@ class RoomTasksDataSource @Inject constructor( // The list and parent come from the master: moving one occurrence between // lists or parents is not something the override model expresses. val id = tasks.insert(fork.copy(listId = master.listId, parentId = master.parentId)) - alarms.replaceAllForTask(id, alarms.forTask(taskId)) + // The occurrence starts out with the series' reminders and then takes + // the form's — the master's own are never touched. + alarms.copyToTask(taskId, id, sameResource = true) + writeReminders(id, reminders) writes.onWritten(setOf(master.listId)) } - override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) { - alarms.replaceEditable( - taskId, - minutesBeforeDue?.let { TaskAlarmEntity(taskId = taskId, minutesBefore = it) }, - ) - } - - override fun setReminders(taskId: Long, reminders: List) { - alarms.replaceAllForTask( - taskId, - reminders.map { - TaskAlarmEntity( - taskId = taskId, - minutesBefore = it.minutesBefore, - reference = if (it.fromStart) AlarmReference.START else AlarmReference.DUE, - ) - }, - ) + override fun setReminders(taskId: Long, reminders: List) = tx { + if (!alarms.syncForTask(taskId, reminders)) return@tx + // The reminders are part of the VTODO now, so changing them is an edit. + val current = tasks.entity(taskId) ?: return@tx + tasks.update(current.copy(isDirty = true, lastModified = clock.now())) + writes.onWritten(setOf(current.listId)) } override fun setCancelled(taskId: Long, cancelled: Boolean) { @@ -473,7 +514,7 @@ class RoomTasksDataSource @Inject constructor( } val (start, due) = occurrenceTimes(master, occurrenceStart) val id = tasks.insert(write(newOverride(master, taskId, occurrenceStart, start, due))) - alarms.replaceAllForTask(id, alarms.forTask(taskId)) + alarms.copyToTask(taskId, id, sameResource = true) writes.onWritten(setOf(master.listId)) } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskAlarmDao.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskAlarmDao.kt index d487509..cd9d9c0 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskAlarmDao.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskAlarmDao.kt @@ -5,6 +5,10 @@ import androidx.room.Insert import androidx.room.Query import androidx.room.Transaction import androidx.room.Update +import de.jeanlucmakiola.agendula.domain.TaskReminder +import de.jeanlucmakiola.agendula.domain.ical.ICalParser +import de.jeanlucmakiola.agendula.domain.ical.ICalValues +import kotlin.time.Instant /** Reads and writes over `task_alarms`. */ @Dao @@ -17,6 +21,9 @@ interface TaskAlarmDao { @Query("SELECT * FROM task_alarms WHERE task_id = :taskId ORDER BY id") fun forTask(taskId: Long): List + /** The reminders of several tasks in one query — one resource's rows, for an upload. */ + @Query("SELECT * FROM task_alarms WHERE task_id IN (:taskIds) ORDER BY id") + fun forTasks(taskIds: List): List @Query("SELECT * FROM task_alarms WHERE id = :id") fun entity(id: Long): TaskAlarmEntity? @@ -32,28 +39,121 @@ interface TaskAlarmDao { @Query("DELETE FROM task_alarms WHERE task_id = :taskId") fun deleteForTask(taskId: Long): Int + @Update + fun update(alarm: TaskAlarmEntity) + + /** See [TaskEntity.remindersOff]. */ + @Query("UPDATE tasks SET reminders_off = :off WHERE id = :taskId") + fun setRemindersOff(taskId: Long, off: Boolean): Int + + /** The tasks whose alarms were turned off elsewhere — "none", not "the default". */ + @Query("SELECT id FROM tasks WHERE reminders_off = 1") + fun remindersOff(): List + /** Set the task's only reminder, or clear every one with `null`. */ @Transaction - fun replaceForTask(taskId: Long, alarm: TaskAlarmEntity?) = replaceAllForTask(taskId, listOfNotNull(alarm)) - - /** - * Replace every reminder on the task. The rows that land are always new ones — - * ids are cleared so alarms lifted off another task (forking an occurrence - * copies the master's) insert instead of colliding. - */ - @Transaction - fun replaceAllForTask(taskId: Long, alarms: List) { + fun replaceForTask(taskId: Long, alarm: TaskAlarmEntity?) { deleteForTask(taskId) - alarms.forEach { insert(it.copy(id = 0, taskId = taskId)) } - } - - /** - * Set the task's editable reminder — its last before-due one — or clear it - * with `null`. Start-relative and additional reminders stay as they are. - */ - @Transaction - fun replaceEditable(taskId: Long, alarm: TaskAlarmEntity?) { - forTask(taskId).lastOrNull { it.reference == AlarmReference.DUE }?.let { delete(it.id) } alarm?.let { insert(it.copy(id = 0, taskId = taskId)) } } + + /** + * Make the task's reminders [wanted], touching only what differs. + * + * ⚠️ A diff, not a delete-and-reinsert. A row that is still wanted keeps its + * [TaskAlarmEntity.uid] and its [TaskAlarmEntity.raw] — which [TaskReminder] + * carries neither of, so a rewrite from it would give every save a new + * alarm identity on the server and throw away another client's `VALARM` the + * user never touched. + * + * Duplicates count: two identical leads are two rows. + * + * @return whether anything changed. + */ + @Transaction + fun syncForTask(taskId: Long, wanted: List): Boolean { + val unmatched = wanted.map { it.lead }.toMutableList() + var changed = false + forTask(taskId).forEach { row -> + if (!unmatched.remove(row.asReminder())) { + delete(row.id) + changed = true + } + } + unmatched.forEach { + insert(it.toAlarmEntity(taskId)) + changed = true + } + // A reminder of its own again, so the "turned off elsewhere" no longer holds. + if (wanted.isNotEmpty()) setRemindersOff(taskId, false) + return changed + } + + /** + * Replace the task's reminders with what a download carried. + * + * Incoming alarms are matched to the rows already here by uid first, then by + * lead, so a reminder the server merely re-described keeps its row rather + * than being churned into a new one. + * + * A download that takes away every alarm the task had marks it + * [TaskEntity.remindersOff]; one that carries alarms clears that. + */ + @Transaction + fun replaceFromServer(taskId: Long, incoming: List) { + val existing = forTask(taskId).toMutableList() + val hadAny = existing.isNotEmpty() + val placed = incoming.map { alarm -> + val match = existing.firstOrNull { it.uid == alarm.uid } + ?: existing.firstOrNull { it.asReminder() == alarm.asReminder() } + match?.let(existing::remove) + alarm.copy(id = match?.id ?: 0, taskId = taskId) + } + existing.forEach { delete(it.id) } + placed.forEach { if (it.id == 0L) insert(it) else update(it) } + when { + incoming.isNotEmpty() -> setRemindersOff(taskId, false) + hadAny -> setRemindersOff(taskId, true) + } + } + + /** + * Copy [fromTaskId]'s reminders onto [toTaskId]. + * + * [sameResource] is for an override forked inside the same series — the + * same `VTODO` UID, so the alarms keep their identity. Anything else is a + * different task on the server and gets fresh uids: two tasks naming one + * alarm is an identity no other client expects. + */ + @Transaction + fun copyToTask(fromTaskId: Long, toTaskId: Long, sameResource: Boolean) { + forTask(fromTaskId).forEach { insert(if (sameResource) it.copy(id = 0, taskId = toTaskId) else it.freshCopy(toTaskId)) } + } +} + +/** What the row means to the scheduler and the editor, without its identity. */ +fun TaskAlarmEntity.asReminder() = TaskReminder(minutesBefore, reference == AlarmReference.START) + +/** The inverse of [asReminder]: a new row, with an identity of its own. */ +fun TaskReminder.toAlarmEntity(taskId: Long) = TaskAlarmEntity( + taskId = taskId, + minutesBefore = minutesBefore, + reference = if (fromStart) AlarmReference.START else AlarmReference.DUE, +) + +/** The same reminder on another task: what it means, never who it is on the server. */ +fun TaskAlarmEntity.freshCopy(taskId: Long) = + TaskAlarmEntity(taskId = taskId, minutesBefore = minutesBefore, reference = reference, message = message) + +/** + * The `ACKNOWLEDGED` stamp of the `VALARM` the row was read from, if any. + * + * Read off [TaskAlarmEntity.raw] rather than kept in a column: it is the other + * client's bookkeeping, and it changes only when a download replaces the row. + */ +fun TaskAlarmEntity.acknowledgedAt(): Instant? { + val text = raw ?: return null + if (!text.contains("ACKNOWLEDGED", ignoreCase = true)) return null + val property = runCatching { ICalParser.parse(text) }.getOrNull()?.property("ACKNOWLEDGED") ?: return null + return (ICalValues.parseTime(property) as? ICalValues.TimeValue.Timed)?.instant } 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 18d4392..a73b2c0 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 @@ -63,6 +63,7 @@ object TaskFormWriter { isAllDay = form.isAllDay, timezone = if (timed) tzId else null, parentId = form.parentId?.takeIf { it > 0 }, + remindersOff = if (form.remindersChosen) form.reminders.isEmpty() else current.remindersOff, lastModified = now, isDirty = true, ) 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 a203d11..9f9ac7e 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 @@ -5,6 +5,8 @@ import androidx.room.RoomDatabase import androidx.room.TypeConverters import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase +import java.util.UUID +import kotlin.time.Instant /** * Agendula's own task store. Four tables, designed from @@ -20,7 +22,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase TaskEntity::class, TaskAlarmEntity::class, ], - version = 2, + version = 3, exportSchema = true, ) @TypeConverters(Converters::class) @@ -63,5 +65,113 @@ abstract class TasksDatabase : RoomDatabase() { } } } + + /** + * v3 syncs reminders as `VALARM`s, which takes two things the rows did not + * have: an identity on the wire (`uid`) and the original text (`raw`). + * + * Claimable `VALARM`s are lifted out of the residue for every row, synced + * or not — an unchanged task is never downloaded again, and an imported + * one never was. Not marked dirty: the server already has them. Where a + * task held the same lead in both stores (an override forked before v3 + * copies the master's alarms *and* carries its `VALARM`s), the lifted + * one wins: it is the one with an identity. + * + * Reminders that so far lived only on this device are pushed once, by + * marking their tasks dirty — bounded to open tasks in synced lists whose + * reminder can still fire, so the upgrade is not a burst of PUTs landing + * on other clients as long-missed alarms. + * + * `tasks.reminders_off` starts at 0 everywhere: until now nothing could + * have turned a synced task's alarms off. + */ + val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE task_alarms ADD COLUMN uid TEXT NOT NULL DEFAULT ''") + db.execSQL("ALTER TABLE task_alarms ADD COLUMN raw TEXT") + db.execSQL("ALTER TABLE tasks ADD COLUMN reminders_off INTEGER NOT NULL DEFAULT 0") + // A version-4 UUID per row, in one statement: `randomblob` is + // evaluated per row, and the upgrade runs on app start. + db.execSQL( + """ + UPDATE task_alarms SET uid = + lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' || + substr(lower(hex(randomblob(2))), 2) || '-' || + substr('89ab', 1 + (abs(random()) % 4), 1) || substr(lower(hex(randomblob(2))), 2) || '-' || + lower(hex(randomblob(6))) + """.trimIndent(), + ) + + data class Lifted(val taskId: Long, val result: AlarmLift.Result) + val lifted = mutableListOf() + db.query( + "SELECT id, dtstart, due, is_all_day, rrule, rdate, unknown_properties FROM tasks " + + "WHERE unknown_properties LIKE '%VALARM%'", + ).use { c -> + while (c.moveToNext()) { + val task = AlarmLift.Task( + dtstart = if (c.isNull(1)) null else Instant.fromEpochMilliseconds(c.getLong(1)), + due = if (c.isNull(2)) null else Instant.fromEpochMilliseconds(c.getLong(2)), + isAllDay = c.getInt(3) != 0, + recurring = !c.isNull(4) || !c.isNull(5), + ) + val result = AlarmLift.lift(c.getString(6), task) + if (result.alarms.isNotEmpty()) lifted += Lifted(c.getLong(0), result) + } + } + lifted.forEach { (taskId, result) -> + val local = mutableListOf>() + db.query( + "SELECT id, minutes_before, reference FROM task_alarms WHERE task_id = ?", + arrayOf(taskId), + ).use { c -> while (c.moveToNext()) local += Triple(c.getLong(0), c.getInt(1), c.getString(2)) } + result.alarms.forEach { alarm -> + local.firstOrNull { it.second == alarm.minutesBefore && it.third == alarm.reference.name } + ?.let { duplicate -> + local.remove(duplicate) + db.execSQL("DELETE FROM task_alarms WHERE id = ?", arrayOf(duplicate.first)) + } + db.execSQL( + "INSERT INTO task_alarms (task_id, minutes_before, reference, uid, raw) VALUES (?, ?, ?, ?, ?)", + arrayOf( + taskId, + alarm.minutesBefore, + alarm.reference.name, + alarm.uid ?: UUID.randomUUID().toString(), + alarm.raw, + ), + ) + } + db.execSQL("UPDATE tasks SET unknown_properties = ? WHERE id = ?", arrayOf(result.residue, taskId)) + } + + // ⚠️ `raw IS NULL` is "never on the server": everything lifted + // above carries its original. Status 0/1 are NEEDS-ACTION and + // IN-PROCESS (`ICalStatus`). + // + // ⚠️ Never a list the upload cannot write to. A dirty row is + // also one the download will not overwrite, so marking a task in + // a read-only share — or a confidential one, which the upload + // skips in a shared collection — would freeze it for good: never + // sent, and the owner's edits never taken. Classification 2 is + // CONFIDENTIAL. + // + // LAST-MODIFIED moves with it: the body changes, and a client + // reconciling by that stamp would otherwise keep its stale copy. + val now = System.currentTimeMillis() + db.execSQL( + """ + UPDATE tasks SET is_dirty = 1, last_modified = ? + WHERE is_deleted = 0 + AND status IN (0, 1) + AND (classification IS NULL OR classification != 2) + AND list_id IN (SELECT id FROM task_lists WHERE account_id IS NOT NULL AND is_read_only = 0) + AND (rrule IS NOT NULL OR rdate IS NOT NULL OR due > ?) + AND id IN (SELECT task_id FROM task_alarms WHERE raw IS NULL) + """.trimIndent(), + arrayOf(now, now), + ) + } + } } } 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 1585bb0..9999e14 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 @@ -7,10 +7,9 @@ import androidx.datastore.preferences.core.edit import de.jeanlucmakiola.agendula.data.di.ExternalStore import de.jeanlucmakiola.agendula.data.di.IoDispatcher import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver -import de.jeanlucmakiola.agendula.data.tasks.TaskReminder +import de.jeanlucmakiola.agendula.domain.TaskReminder import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource -import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference -import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity +import de.jeanlucmakiola.agendula.data.tasks.room.toAlarmEntity import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase @@ -200,7 +199,7 @@ class ExternalImport @Inject constructor( var alarmCount = 0 for ((sourceTaskId, reminders) in snapshot.alarms) { val taskId = taskIds[sourceTaskId] ?: continue - reminders.forEach { alarmDao.insert(it.toEntity(taskId)) } + reminders.forEach { alarmDao.insert(it.toAlarmEntity(taskId)) } alarmCount += reminders.size } @@ -228,12 +227,6 @@ class ExternalImport @Inject constructor( lastModified = lastModified, ) - private fun TaskReminder.toEntity(taskId: Long) = TaskAlarmEntity( - taskId = taskId, - minutesBefore = minutesBefore, - reference = if (fromStart) AlarmReference.START else AlarmReference.DUE, - ) - private fun verify(written: TransferCounts, before: TransferCounts) { val after = tableCounts() check(after.lists - before.lists == written.lists) { diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/transfer/IcsImport.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/transfer/IcsImport.kt index 41c4929..18bbc93 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/transfer/IcsImport.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/transfer/IcsImport.kt @@ -7,7 +7,9 @@ import dagger.hilt.android.qualifiers.ApplicationContext import de.jeanlucmakiola.agendula.data.di.IoDispatcher import de.jeanlucmakiola.agendula.data.tasks.ical.CalendarResource import de.jeanlucmakiola.agendula.data.tasks.ical.VTodoMapper +import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity +import de.jeanlucmakiola.agendula.data.tasks.room.freshCopy import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase import kotlinx.coroutines.CoroutineDispatcher @@ -65,7 +67,11 @@ interface IcsImportStore { fun transaction(block: () -> T): T fun createList(name: String, color: Int): Long fun hasUid(listId: Long, uid: String): Boolean + + /** Whether any list holds [uid] — the same file imported somewhere else before. */ + fun hasUidAnywhere(uid: String): Boolean fun insert(row: TaskEntity): Long + fun insertAlarms(taskId: Long, alarms: List) fun masterByUid(listId: Long, uid: String): TaskEntity? fun setParent(taskId: Long, parentId: Long?) } @@ -76,9 +82,9 @@ interface IcsImportStore { * * Goes through the same [CalendarResource] / [VTodoMapper] path CalDAV sync * uses, so recurrence, `RECURRENCE-ID` overrides, `RELATED-TO` parents and every - * property the mapper does not claim (`VALARM`s included) survive as they would - * on a download. `VALARM`s stay in the residue: like sync, the import does not - * turn them into local reminders. + * property the mapper does not claim survive as they would on a download. A + * `VALARM` the mapper claims becomes a reminder on the task, exactly as a + * download does; the rest stay in the residue. * * Tasks whose UID the target list already holds are skipped, so importing the * same file twice is harmless. @@ -200,8 +206,17 @@ class IcsImport @Inject constructor( skipped++ continue } + // ⚠️ The same file in a second list is a second task carrying + // the first one's alarm identities. Those are what clients + // track a dismissal by, so the copy's alarms are new ones. + val copied = store.hasUidAnywhere(task.uid) + fun alarmsOf(mapped: VTodoMapper.Mapped) = + if (copied) mapped.alarms.map { it.freshCopy(0) } else mapped.alarms val masterId = store.insert(task.master.entity.forImport(listId, masterId = null)) - task.overrides.forEach { store.insert(it.entity.forImport(listId, masterId)) } + store.insertAlarms(masterId, alarmsOf(task.master)) + task.overrides.forEach { + store.insertAlarms(store.insert(it.entity.forImport(listId, masterId)), alarmsOf(it)) + } task.master.parentUid?.let { parents += masterId to it } imported++ } @@ -270,8 +285,15 @@ class RoomIcsImportStore(private val database: TasksDatabase) : IcsImportStore { arrayOf(listId, uid), ).use { it.moveToFirst() } + override fun hasUidAnywhere(uid: String): Boolean = + database.query("SELECT 1 FROM tasks WHERE uid = ? LIMIT 1", arrayOf(uid)).use { it.moveToFirst() } + override fun insert(row: TaskEntity): Long = database.tasks().insert(row) + override fun insertAlarms(taskId: Long, alarms: List) { + alarms.forEach { database.alarms().insert(it.copy(id = 0, taskId = taskId)) } + } + override fun masterByUid(listId: Long, uid: String): TaskEntity? = database.tasks().byUid(listId, uid) override fun setParent(taskId: Long, parentId: Long?) { diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskForm.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskForm.kt index 9adec18..9bf41ec 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskForm.kt @@ -18,8 +18,10 @@ data class TaskForm( val parentId: Long? = null, /** 0..100 progress; `null` leaves it unset (cleared). */ val percentComplete: Int? = null, - /** The task's own reminders, in minutes before [due]; empty = the list's or app default applies. */ - val reminders: List = emptyList(), + /** The task's own reminders; empty = the list's or app default applies, unless [remindersChosen]. */ + val reminders: List = emptyList(), + /** Whether [reminders] are exactly the task's, as the editor showed them: empty is then none. */ + val remindersChosen: Boolean = false, val location: String? = null, val url: String? = null, val tags: List = emptyList(), @@ -30,14 +32,14 @@ data class TaskForm( if (title.isBlank()) add(TaskFormError.BLANK_TITLE) if (listId <= 0L) add(TaskFormError.NO_LIST) if (start != null && due != null && due < start) add(TaskFormError.DUE_BEFORE_START) - if (reminders.isNotEmpty() && due == null) add(TaskFormError.REMINDER_WITHOUT_DUE) + if (reminders.isNotEmpty() && start == null && due == null) add(TaskFormError.REMINDER_WITHOUT_DATE) if (rrule != null && start == null && due == null) add(TaskFormError.RECURRENCE_WITHOUT_DATE) } val isValid: Boolean get() = validate().isEmpty() } -enum class TaskFormError { BLANK_TITLE, NO_LIST, DUE_BEFORE_START, REMINDER_WITHOUT_DUE, RECURRENCE_WITHOUT_DATE } +enum class TaskFormError { BLANK_TITLE, NO_LIST, DUE_BEFORE_START, REMINDER_WITHOUT_DATE, RECURRENCE_WITHOUT_DATE } /** * Optional edit-form sections that can be shown or tucked behind "More fields" diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskReminder.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskReminder.kt new file mode 100644 index 0000000..ace8bef --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskReminder.kt @@ -0,0 +1,85 @@ +package de.jeanlucmakiola.agendula.domain + +import de.jeanlucmakiola.floret.reminders.ReminderLead +import kotlin.time.Instant + +const val DAY_MINUTES = 24 * 60 + +/** + * The earliest lead an all-day reminder can have: 00:01 on the day. On an + * all-day task a lead that is not whole days counts back from the date's + * midnight, so `-540` is 09:00 on the day — see [TaskReminder.allDay]. + */ +const val MIN_ALL_DAY_LEAD = -(DAY_MINUTES - 1) + +/** A stored reminder: how long before the start ([fromStart]) or the due date. */ +data class TaskReminder( + val minutesBefore: Int, + val fromStart: Boolean = false, + /** + * When another client last dismissed this alarm (RFC 9074 `ACKNOWLEDGED`): + * a trigger at or before it has been dealt with and is not fired here. + * Never part of what a reminder *is* — see [lead]. + */ + val acknowledgedAt: Instant? = null, +) { + /** The reminder without its bookkeeping, for comparing two of them. */ + val lead: TaskReminder get() = if (acknowledgedAt == null) this else copy(acknowledgedAt = null) + + /** This reminder read as an all-day one. */ + val allDay: AllDayReminder get() = AllDayReminder.of(minutesBefore) + + /** This reminder's lead rounded to the nearest whole day, as the earlier all-day leads were. */ + fun inWholeDays(): TaskReminder = + copy(minutesBefore = (minutesBefore.coerceAtLeast(0) + DAY_MINUTES / 2) / DAY_MINUTES * DAY_MINUTES) + + companion object { + /** + * The all-day reminder [daysBefore] the date at [minuteOfDay], or at the + * device's all-day time when that is null. Midnight would read back as + * the latter, so it is 00:01 — see [allDayMinuteOfDay]. + */ + fun allDay(daysBefore: Int, minuteOfDay: Int?, fromStart: Boolean = false) = + TaskReminder(daysBefore * DAY_MINUTES - (minuteOfDay?.let(::allDayMinuteOfDay) ?: 0), fromStart) + + /** The time an all-day reminder can name: any but midnight, which is a whole-day lead. */ + fun allDayMinuteOfDay(minuteOfDay: Int): Int = minuteOfDay.coerceAtLeast(1) + } +} + +/** An all-day reminder as the day it rings on and, when it names one, its time. */ +data class AllDayReminder(val daysBefore: Int, val minuteOfDay: Int?) { + companion object { + /** [minutesBefore] on an all-day task; anything after the day is clamped to its last minute. */ + fun of(minutesBefore: Int): AllDayReminder { + val minutes = minutesBefore.coerceAtLeast(MIN_ALL_DAY_LEAD) + if (minutes % DAY_MINUTES == 0) return AllDayReminder(minutes / DAY_MINUTES, minuteOfDay = null) + val days = Math.floorDiv(minutes, DAY_MINUTES) + 1 + return AllDayReminder(days, minuteOfDay = days * DAY_MINUTES - minutes) + } + } +} + +/** A canonical order, for comparing two sets of reminders. */ +val TaskReminderOrder: Comparator = + compareBy({ !it.fromStart }, { -it.minutesBefore }) + +/** + * The order the editor and the detail screen list reminders in: start's first, + * then earliest first. An all-day reminder without a time rings at [allDayMinuteOfDay]. + */ +fun taskReminderDisplayOrder(allDay: Boolean, allDayMinuteOfDay: Int): Comparator = + compareBy({ !it.fromStart }, { reminder -> + if (!allDay) { + -reminder.minutesBefore + } else { + val (days, minuteOfDay) = reminder.allDay + (minuteOfDay ?: allDayMinuteOfDay) - days * DAY_MINUTES + } + }) + +/** A default reminder's lead as a reminder of the task's own. */ +fun ReminderLead.asTaskReminder(): TaskReminder = TaskReminder(minutes, fromStart) + +/** This reminder's lead, as default reminders are kept. */ +fun TaskReminder.asReminderLead(): ReminderLead = ReminderLead(minutesBefore, fromStart) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/ical/ICalValues.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/ical/ICalValues.kt index 32422f8..f5bc0e1 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/domain/ical/ICalValues.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/ical/ICalValues.kt @@ -161,6 +161,57 @@ object ICalValues { } } + private val DURATION = Regex( + "([+-])?P(?:(\\d+)W|(?:(\\d+)D)?(?:T(?:(\\d+)H)?(?:(\\d+)M)?(?:(\\d+)S)?)?)", + RegexOption.IGNORE_CASE, + ) + + /** + * RFC 5545 §3.3.6 `dur-value` in signed seconds: `-PT15M` → -900, `P1W` → + * 604800, `-P1DT2H` → -93600. Null when it does not parse. + * + * A little more lenient than the grammar — `PT1H5S` skips the minutes the + * grammar would require — because a stricter reader would leave another + * client's alarm unread over a form nobody misreads. + */ + fun parseDuration(value: String): Long? { + val match = DURATION.matchEntire(value.trim()) ?: return null + val (sign, weeks, days, hours, minutes, seconds) = match.destructured + // "P" and "PT" alone match the pattern and mean nothing. + if (listOf(weeks, days, hours, minutes, seconds).all(String::isEmpty)) return null + if (value.trim().endsWith("T", ignoreCase = true)) return null + fun part(text: String, scale: Long): Long = + if (text.isEmpty()) 0L else Math.multiplyExact(text.toLong(), scale) + val total = runCatching { + listOf( + part(weeks, 7 * 86_400L), part(days, 86_400L), + part(hours, 3_600L), part(minutes, 60L), part(seconds, 1L), + ).reduce(Math::addExact) + }.getOrNull() ?: return null + return if (sign == "-") -total else total + } + + /** The inverse of [parseDuration], in days and time, never weeks: `-PT15M`, `-P1D`, `PT0S`. */ + fun formatDuration(seconds: Long): String { + if (seconds == 0L) return "PT0S" + val magnitude = kotlin.math.abs(seconds) + val days = magnitude / 86_400 + val hours = magnitude % 86_400 / 3_600 + val minutes = magnitude % 3_600 / 60 + val rest = magnitude % 60 + return buildString { + if (seconds < 0) append('-') + append('P') + if (days > 0) append(days).append('D') + if (hours > 0 || minutes > 0 || rest > 0) { + append('T') + if (hours > 0) append(hours).append('H') + if (minutes > 0) append(minutes).append('M') + if (rest > 0) append(rest).append('S') + } + } + } + private fun java.time.Instant.toKotlin(): Instant = Instant.fromEpochMilliseconds(toEpochMilli()) } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ReminderFormatting.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ReminderFormatting.kt index 9fabff3..2781143 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ReminderFormatting.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ReminderFormatting.kt @@ -5,6 +5,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.domain.DAY_MINUTES +import de.jeanlucmakiola.agendula.domain.TaskReminder import de.jeanlucmakiola.floret.reminders.ReminderUnit /** Common reminder lead times offered as quick picks in the reminder pickers. */ @@ -33,9 +35,57 @@ fun reminderLeadTimeLabel(minutes: Int, allDay: Boolean = false): String = when minutes <= 0 -> stringResource(R.string.reminder_at_due) minutes % 10_080 == 0 -> pluralStringResource(R.plurals.reminder_weeks, minutes / 10_080, minutes / 10_080) - minutes % 1_440 == 0 -> - pluralStringResource(R.plurals.reminder_days, minutes / 1_440, minutes / 1_440) + minutes % DAY_MINUTES == 0 -> + pluralStringResource(R.plurals.reminder_days, minutes / DAY_MINUTES, minutes / DAY_MINUTES) minutes % 60 == 0 -> pluralStringResource(R.plurals.reminder_hours, minutes / 60, minutes / 60) else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes) } + +/** + * [reminderLeadTimeLabel] naming the date the lead counts from, for a task + * that has both: "15 minutes before the start", "On the due date". + */ +@Composable +fun anchoredLeadTimeLabel(minutes: Int, allDay: Boolean, fromStart: Boolean): String { + @Composable + fun plural(start: Int, due: Int, count: Int) = + pluralStringResource(if (fromStart) start else due, count, count) + return when { + minutes <= 0 && allDay -> + stringResource(if (fromStart) R.string.reminder_on_start_date else R.string.reminder_on_due_date) + minutes <= 0 -> stringResource(if (fromStart) R.string.reminder_at_start else R.string.reminder_at_due_time) + minutes % 10_080 == 0 -> + plural(R.plurals.reminder_weeks_before_start, R.plurals.reminder_weeks_before_due, minutes / 10_080) + minutes % DAY_MINUTES == 0 -> + plural(R.plurals.reminder_days_before_start, R.plurals.reminder_days_before_due, minutes / DAY_MINUTES) + minutes % 60 == 0 -> + plural(R.plurals.reminder_hours_before_start, R.plurals.reminder_hours_before_due, minutes / 60) + else -> plural(R.plurals.reminder_minutes_before_start, R.plurals.reminder_minutes_before_due, minutes) + } +} + +/** A lead's label, naming its date when [anchored]. */ +@Composable +fun reminderLabel(minutes: Int, allDay: Boolean, fromStart: Boolean, anchored: Boolean): String = + if (anchored) anchoredLeadTimeLabel(minutes, allDay, fromStart) else reminderLeadTimeLabel(minutes, allDay) + +/** + * One of a task's own reminders in one line, naming its date when [anchored]. + * An all-day one names its day and the time it rings at — its own, or + * [allDayMinuteOfDay] when it has none. + */ +@Composable +fun taskReminderLabel(reminder: TaskReminder, allDay: Boolean, allDayMinuteOfDay: Int, anchored: Boolean): String { + if (!allDay) return reminderLabel(reminder.minutesBefore, false, reminder.fromStart, anchored) + val (days, minuteOfDay) = reminder.allDay + return stringResource( + R.string.reminder_at_time, + reminderLabel(days * DAY_MINUTES, true, reminder.fromStart, anchored), + formatMinuteOfDay(minuteOfDay ?: allDayMinuteOfDay, LocalUse24HourFormat.current), + ) +} + +@StringRes +fun reminderAnchorLabel(fromStart: Boolean): Int = + if (fromStart) R.string.reminder_anchor_start else R.string.reminder_anchor_due diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ReminderLeadPicker.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ReminderLeadPicker.kt index cb1f4ea..c1cbd1a 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ReminderLeadPicker.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ReminderLeadPicker.kt @@ -1,12 +1,13 @@ package de.jeanlucmakiola.agendula.ui.common +import de.jeanlucmakiola.floret.components.CustomAmountEditor import de.jeanlucmakiola.floret.components.FullScreenPicker import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.positionOf -import de.jeanlucmakiola.floret.components.DialogAmountField import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.SelectedCheck -import de.jeanlucmakiola.floret.reminders.reminderOverrideForMinutes +import de.jeanlucmakiola.floret.reminders.ReminderLead +import de.jeanlucmakiola.floret.reminders.reminderOverrideForLeads import androidx.compose.foundation.layout.height import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember @@ -16,27 +17,19 @@ import de.jeanlucmakiola.floret.reminders.decomposeReminderMinutes import androidx.compose.animation.AnimatedVisibility import de.jeanlucmakiola.floret.identity.collapseExit import de.jeanlucmakiola.floret.identity.expandEnter -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -46,8 +39,10 @@ import de.jeanlucmakiola.agendula.R * Reminder lead-time picker, full-screen: the grouped list of preset lead times * plus a "Custom" row that expands an inline amount field + unit selector. * [allowInherit] adds a "Use default" row (per-list overrides); [allowNone] adds - * a "None" row; [allDay] switches to day-scale presets and wording. Returns the - * choice as a [ReminderOverride]. Ported from Calendula's ReminderDefaultPicker. + * a "None" row; [allDay] switches to day-scale presets and wording, and its + * custom amounts to days and weeks. [header] sits above the rows, for whatever + * else the caller's reminder carries. Returns the choice as a + * [ReminderOverride]. Ported from Calendula's ReminderDefaultPicker. */ @Composable fun ReminderLeadPicker( @@ -59,6 +54,7 @@ fun ReminderLeadPicker( onDismiss: () -> Unit, allDay: Boolean = false, presets: List = if (allDay) ALLDAY_REMINDER_PRESETS else REMINDER_PRESETS, + header: (@Composable ColumnScope.() -> Unit)? = null, ) { // Agendula is single-reminder: an override carries a one-element list, so // take the single value for this single-select picker. @@ -68,7 +64,7 @@ fun ReminderLeadPicker( var customExpanded by rememberSaveable { mutableStateOf(false) } var amountText by rememberSaveable { mutableStateOf(seed.amount?.toString() ?: "") } - var unit by rememberSaveable { mutableStateOf(seed.unit) } + var unit by rememberSaveable { mutableStateOf(seed.unit.takeIf { it in reminderUnits(allDay) } ?: reminderUnits(allDay).first()) } val options = buildList { if (allowInherit) add(ReminderOverride.Inherit) @@ -78,6 +74,7 @@ fun ReminderLeadPicker( val rowCount = options.size + 1 // + the custom row FullScreenPicker(title = title, onDismiss = onDismiss) { + header?.invoke(this) options.forEachIndexed { index, option -> val isSelected = option == selected GroupedRow( @@ -112,6 +109,8 @@ fun ReminderLeadPicker( onAmountChange = { amountText = it }, unit = unit, onUnitChange = { unit = it }, + units = reminderUnits(allDay), + allDay = allDay, onConfirm = { minutes -> onSelect(ReminderOverride.Minutes(listOf(minutes))) onDismiss() @@ -129,7 +128,9 @@ fun ReminderLeadPicker( * Clearing the last time falls back to "Use default" on a per-list picker and to * "No reminder" on the global one. Changes apply live; the user leaves via back. * [allDay] switches to day-scale presets, and [leadTimeSummary] adds a second - * line to each lead-time row. Ported from Calendula's ReminderDefaultPicker. + * line to each lead-time row. A start / due toggle picks which date the rows + * count from, and the choice carries it ([ReminderOverride.Minutes.fromStart]). + * Ported from Calendula's ReminderDefaultPicker. */ @Composable fun ReminderDefaultPicker( @@ -149,24 +150,27 @@ fun ReminderDefaultPicker( LaunchedEffect(selected) { if (!userEdited) current = selected } val inherits = current is ReminderOverride.Inherit val isNone = current is ReminderOverride.None - val selectedMinutes = (current as? ReminderOverride.Minutes)?.minutes.orEmpty() + val selectedLeads = (current as? ReminderOverride.Minutes)?.leads.orEmpty() + var fromStart by rememberSaveable { mutableStateOf(true) } + val selectedMinutes = selectedLeads.filter { it.fromStart == fromStart }.map { it.minutes } // Custom values seen this session keep their row once unchecked. - val seenCustom = remember { mutableSetOf() } - seenCustom += selectedMinutes.filter { it !in presets } - val rows = presets + seenCustom.sorted() + val seenCustom = remember { mutableSetOf() } + seenCustom += selectedLeads.filter { it.minutes !in presets } + val rows = presets + seenCustom.filter { it.fromStart == fromStart }.map { it.minutes }.sorted() var customExpanded by rememberSaveable { mutableStateOf(false) } var amountText by rememberSaveable { mutableStateOf("") } - var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) } + var unit by rememberSaveable { mutableStateOf(reminderUnits(allDay).first()) } fun apply(override: ReminderOverride) { userEdited = true current = override onSelect(override) } - fun emit(minutes: List) = apply(reminderOverrideForMinutes(minutes, allowInherit)) + fun emit(leads: List) = apply(reminderOverrideForLeads(leads, allowInherit)) + fun lead(minute: Int) = ReminderLead(minute, fromStart) fun toggle(minute: Int) = - emit(if (minute in selectedMinutes) selectedMinutes - minute else selectedMinutes + minute) + emit(if (minute in selectedMinutes) selectedLeads - lead(minute) else selectedLeads + lead(minute)) FullScreenPicker(title = title, onDismiss = onDismiss) { if (allowInherit) { @@ -186,6 +190,8 @@ fun ReminderDefaultPicker( onClick = { apply(ReminderOverride.None) }, ) Spacer(Modifier.height(24.dp)) + ReminderAnchorToggle(fromStart = fromStart, onChange = { fromStart = it }) + Spacer(Modifier.height(16.dp)) val rowCount = rows.size + 1 // + the custom row rows.forEachIndexed { index, minute -> val checked = minute in selectedMinutes @@ -213,8 +219,10 @@ fun ReminderDefaultPicker( onAmountChange = { amountText = it }, unit = unit, onUnitChange = { unit = it }, + units = reminderUnits(allDay), + allDay = allDay, onConfirm = { minutes -> - emit(selectedMinutes + minutes) + emit(selectedLeads + lead(minutes)) amountText = "" customExpanded = false }, @@ -223,57 +231,54 @@ fun ReminderDefaultPicker( } } +/** Which date a reminder counts back from. */ +@Composable +fun ReminderAnchorToggle(fromStart: Boolean, onChange: (Boolean) -> Unit) { + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { + listOf(true, false).forEachIndexed { index, start -> + SegmentedButton( + selected = fromStart == start, + onClick = { onChange(start) }, + shape = SegmentedButtonDefaults.itemShape(index, 2), + label = { Text(stringResource(reminderAnchorLabel(start))) }, + ) + } + } +} + @Composable private fun CustomReminderEditor( amountText: String, onAmountChange: (String) -> Unit, unit: ReminderUnit, onUnitChange: (ReminderUnit) -> Unit, + units: List, + allDay: Boolean, onConfirm: (Int) -> Unit, ) { val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 } - Surface( - color = MaterialTheme.colorScheme.surfaceContainerHigh, - // A Position.Bottom shape: tight top corners meeting the row, full bottom. - shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp), - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { - ReminderUnit.entries.forEachIndexed { index, entry -> - SegmentedButton( - selected = unit == entry, - onClick = { onUnitChange(entry) }, - shape = SegmentedButtonDefaults.itemShape(index, ReminderUnit.entries.size), - label = { Text(stringResource(reminderUnitLabel(entry))) }, - ) - } - } - Row(verticalAlignment = Alignment.CenterVertically) { - DialogAmountField(value = amountText, onValueChange = onAmountChange, placeholder = "10") - Spacer(Modifier.width(16.dp)) - Text( - text = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) } - ?: stringResource(R.string.reminder_custom_amount), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(1f), - ) - Spacer(Modifier.width(16.dp)) - FilledTonalButton( - onClick = { amount?.let { onConfirm(it * unit.minutesFactor) } }, - enabled = amount != null, - ) { - Text(stringResource(R.string.reminder_custom_set)) - } - } - } - } + CustomAmountEditor( + amountText = amountText, + onAmountChange = onAmountChange, + preview = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor, allDay) } + ?: stringResource(R.string.reminder_custom_amount), + setLabel = stringResource(R.string.reminder_custom_set), + confirmEnabled = amount != null, + onConfirm = { amount?.let { onConfirm(it * unit.minutesFactor) } }, + unitLabels = units.map { stringResource(reminderUnitLabel(it)) }, + selectedUnit = units.indexOf(unit), + onUnitChange = { onUnitChange(units[it]) }, + ) } +/** + * The units a custom lead is entered in. An all-day reminder's time of day is + * set on its own, so its lead is whole days: an hour count would read back as a + * time of day. + */ +private fun reminderUnits(allDay: Boolean): List = + if (allDay) listOf(ReminderUnit.Days, ReminderUnit.Weeks) else ReminderUnit.entries + @Composable private fun reminderOverrideLabel(override: ReminderOverride, allDay: Boolean): String = when (override) { ReminderOverride.Inherit -> stringResource(R.string.reminder_use_default) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailScreen.kt index 8a191a4..ca895bd 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailScreen.kt @@ -58,7 +58,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.pluralStringResource import androidx.core.net.toUri -import de.jeanlucmakiola.agendula.data.tasks.TaskReminder +import de.jeanlucmakiola.agendula.domain.TaskReminder import de.jeanlucmakiola.agendula.domain.TaskStatus import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat @@ -66,7 +66,7 @@ import de.jeanlucmakiola.agendula.ui.common.RecurringScopeDialog import de.jeanlucmakiola.agendula.ui.common.TagChip import de.jeanlucmakiola.agendula.ui.common.linkified import de.jeanlucmakiola.agendula.ui.common.recurrenceText -import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel +import de.jeanlucmakiola.agendula.ui.common.taskReminderLabel import de.jeanlucmakiola.floret.locale.currentLocale import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -198,6 +198,7 @@ fun TaskDetailScreen( detail = s.detail, inner = inner, reminders = s.reminders, + allDayMinuteOfDay = s.allDayMinuteOfDay, onToggle = { viewModel.toggleComplete(s.detail.task) }, onToggleSubtask = { viewModel.toggleComplete(it) }, onAddSubtask = { viewModel.addSubtask(it) }, @@ -251,6 +252,7 @@ private fun DetailBody( detail: TaskDetail, inner: androidx.compose.foundation.layout.PaddingValues, reminders: List, + allDayMinuteOfDay: Int, onToggle: () -> Unit, onToggleSubtask: (Task) -> Unit, onAddSubtask: (String) -> Unit, @@ -352,8 +354,12 @@ private fun DetailBody( DetailCard(icon = Icons.Rounded.Notifications, iconContentDescription = stringResource(R.string.edit_reminder_label)) { reminders.forEach { reminder -> Text( - if (reminder.fromStart) stringResource(R.string.detail_reminder_before_start, reminderLeadTimeLabel(reminder.minutesBefore)) - else reminderLeadTimeLabel(reminder.minutesBefore, task.isAllDay), + taskReminderLabel( + reminder, + task.isAllDay, + allDayMinuteOfDay, + anchored = task.start != null && task.due != null, + ), style = MaterialTheme.typography.titleMedium, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailViewModel.kt index acf3a0e..d0f755c 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailViewModel.kt @@ -18,6 +18,11 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import de.jeanlucmakiola.agendula.data.prefs.DEFAULT_ALL_DAY_REMINDER_MINUTE +import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.agendula.domain.taskReminderDisplayOrder import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope import de.jeanlucmakiola.agendula.data.di.ApplicationScope import kotlinx.coroutines.CoroutineScope @@ -30,7 +35,9 @@ sealed interface TaskDetailUiState { data class Content( val detail: TaskDetail, /** The task's own reminders; empty when the list's or app default applies. */ - val reminders: List = emptyList(), + val reminders: List = emptyList(), + /** The time an all-day reminder without one of its own rings at. */ + val allDayMinuteOfDay: Int = DEFAULT_ALL_DAY_REMINDER_MINUTE, ) : TaskDetailUiState } @@ -38,6 +45,7 @@ sealed interface TaskDetailUiState { @HiltViewModel class TaskDetailViewModel @Inject constructor( private val repository: TasksRepository, + private val settingsPrefs: SettingsPrefs, @ApplicationScope private val appScope: CoroutineScope, ) : ViewModel() { @@ -46,14 +54,18 @@ class TaskDetailViewModel @Inject constructor( val state: StateFlow = target.filterNotNull() .flatMapLatest { (id, occurrence) -> + val allDayMinute = settingsPrefs.settings.map { it.allDayReminderMinuteOfDay }.distinctUntilChanged() repository.taskDetail(id, occurrence) - .map { detail -> - if (detail == null) { + .map { detail -> detail?.let { it to repository.remindersFor(it.task.taskId) } } + .combine(allDayMinute) { loaded, minute -> + if (loaded == null) { TaskDetailUiState.NotFound } else { + val (detail, reminders) = loaded TaskDetailUiState.Content( detail = detail, - reminders = repository.remindersFor(detail.task.taskId), + reminders = reminders.sortedWith(taskReminderDisplayOrder(detail.task.isAllDay, minute)), + allDayMinuteOfDay = minute, ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditScreen.kt index 62d6856..0dc11e0 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditScreen.kt @@ -26,11 +26,14 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import de.jeanlucmakiola.agendula.ui.common.RecurringScopeDialog +import de.jeanlucmakiola.agendula.ui.common.ReminderAnchorToggle import de.jeanlucmakiola.agendula.ui.common.ReminderLeadPicker import de.jeanlucmakiola.agendula.ui.common.icon import de.jeanlucmakiola.agendula.ui.common.labelRes import de.jeanlucmakiola.agendula.ui.common.recurrenceText -import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel +import de.jeanlucmakiola.agendula.ui.common.TimePickerAlert +import de.jeanlucmakiola.agendula.ui.common.formatMinuteOfDay +import de.jeanlucmakiola.agendula.ui.common.taskReminderLabel import de.jeanlucmakiola.floret.components.revealOnFocus import de.jeanlucmakiola.floret.components.FullScreenPicker import de.jeanlucmakiola.floret.components.GroupedListInset @@ -123,6 +126,8 @@ import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskFormError import de.jeanlucmakiola.agendula.domain.TaskFormField import de.jeanlucmakiola.agendula.domain.TaskList +import de.jeanlucmakiola.agendula.domain.TaskReminder +import de.jeanlucmakiola.agendula.domain.DAY_MINUTES import de.jeanlucmakiola.agendula.domain.TaskSection import de.jeanlucmakiola.agendula.domain.TaskSections import de.jeanlucmakiola.floret.components.GroupedRow @@ -263,6 +268,8 @@ private fun EditContent( // the title takes the target list's colour. val accent = selectedList?.let { pastelize(it.color, dark) } ?: MaterialTheme.colorScheme.primary val gap = 12.dp + // A reminder's anchor only says something when the task has both to choose from. + val bothDates = state.start != null && state.due != null var pickerTarget by rememberSaveable { mutableStateOf(null) } var showListPicker by rememberSaveable { mutableStateOf(false) } @@ -559,23 +566,25 @@ private fun EditContent( Spacer(Modifier.height(gap)) // One card per reminder, connected, the last row adding another. val rows = state.reminders.size + 1 - state.reminders.forEachIndexed { index, minutes -> + state.reminders.forEachIndexed { index, reminder -> ReminderItemCard( position = positionOf(index, rows), - title = reminderLeadTimeLabel(minutes, state.isAllDay), - onRemove = { viewModel.removeReminder(minutes) }, + title = taskReminderLabel(reminder, state.isAllDay, state.allDayMinuteOfDay, anchored = bothDates), + onRemove = { viewModel.removeReminder(reminder) }, ) } AddReminderCard( position = positionOf(state.reminders.size, rows), label = stringResource( - if (state.reminders.isEmpty()) R.string.edit_reminder_add_first else R.string.edit_reminder_add, + // Only a set the user has not chosen still falls back to the default. + if (state.reminders.isEmpty() && !state.remindersChosen) R.string.edit_reminder_add_first + else R.string.edit_reminder_add, ), onClick = { showReminderPicker = true }, ) - if (TaskFormError.REMINDER_WITHOUT_DUE in state.errors) { + if (TaskFormError.REMINDER_WITHOUT_DATE in state.errors) { Spacer(Modifier.height(4.dp)) - FieldError(R.string.error_reminder_without_due) + FieldError(R.string.error_reminder_without_date) } } @@ -688,18 +697,73 @@ private fun EditContent( } if (showReminderPicker) { + var fromStart by rememberSaveable { mutableStateOf(state.start != null) } + var minuteOfDay by rememberSaveable { mutableIntStateOf(state.allDayMinuteOfDay) } + var pickingTime by rememberSaveable { mutableStateOf(false) } ReminderLeadPicker( title = stringResource(R.string.edit_reminder_add), selected = ReminderOverride.Inherit, allowInherit = false, allowNone = false, allDay = state.isAllDay, + header = if (bothDates || state.isAllDay) { + { + if (bothDates) { + ReminderAnchorToggle(fromStart = fromStart, onChange = { fromStart = it }) + Spacer(Modifier.height(16.dp)) + } + if (state.isAllDay) { + GroupedRow( + title = stringResource(R.string.reminder_time), + position = Position.Alone, + trailing = { + Text( + formatMinuteOfDay(minuteOfDay, LocalUse24HourFormat.current), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + ) + }, + onClick = { pickingTime = true }, + ) + Spacer(Modifier.height(24.dp)) + } + } + } else { + null + }, onSelect = { choice -> - (choice as? ReminderOverride.Minutes)?.minutes?.firstOrNull()?.let(viewModel::addReminder) + (choice as? ReminderOverride.Minutes)?.minutes?.firstOrNull()?.let { minutes -> + // A task with one date counts from that one, whatever the toggle said. + val anchorStart = if (bothDates) fromStart else state.due == null + viewModel.addReminder( + if (state.isAllDay) { + // The default time is left unnamed, so the reminder follows the setting. + TaskReminder.allDay( + daysBefore = minutes / DAY_MINUTES, + minuteOfDay = minuteOfDay.takeIf { it != state.allDayMinuteOfDay }, + fromStart = anchorStart, + ) + } else { + TaskReminder(minutes, fromStart = anchorStart) + }, + ) + } showReminderPicker = false }, onDismiss = { showReminderPicker = false }, ) + if (pickingTime) { + TimePickerAlert( + initial = LocalTime.of(minuteOfDay / 60, minuteOfDay % 60), + onConfirm = { + val picked = it.hour * 60 + it.minute + // The default time stays unnamed; any other is one an all-day reminder can name. + minuteOfDay = if (picked == state.allDayMinuteOfDay) picked else TaskReminder.allDayMinuteOfDay(picked) + pickingTime = false + }, + onDismiss = { pickingTime = false }, + ) + } } if (showRecurrencePicker) { diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditViewModel.kt index 6ad7485..e01423d 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditViewModel.kt @@ -3,6 +3,8 @@ package de.jeanlucmakiola.agendula.ui.edit import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import de.jeanlucmakiola.agendula.data.prefs.DEFAULT_ALL_DAY_REMINDER_MINUTE +import de.jeanlucmakiola.agendula.data.prefs.Settings import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler import de.jeanlucmakiola.agendula.data.tasks.TaskConflictException @@ -16,6 +18,10 @@ import de.jeanlucmakiola.agendula.domain.TaskFormError import de.jeanlucmakiola.agendula.domain.TaskFormField import de.jeanlucmakiola.agendula.domain.allDayInstantOf import de.jeanlucmakiola.agendula.domain.TaskList +import de.jeanlucmakiola.agendula.domain.TaskReminder +import de.jeanlucmakiola.agendula.domain.taskReminderDisplayOrder +import de.jeanlucmakiola.agendula.data.tasks.StoredReminders +import java.time.ZoneId import de.jeanlucmakiola.agendula.domain.populatedFields import de.jeanlucmakiola.agendula.domain.htmlToPlainText import de.jeanlucmakiola.agendula.domain.looksLikeHtml @@ -54,7 +60,13 @@ data class TaskEditUiState( val priority: Priority = Priority.NONE, val parentId: Long? = null, val percentComplete: Int? = null, - val reminders: List = emptyList(), + val reminders: List = emptyList(), + /** Whether the user added or removed a reminder; until then the defaults follow the form. */ + val remindersTouched: Boolean = false, + /** Whether [reminders] are stored as they are — see [TaskForm.remindersChosen]. */ + val remindersChosen: Boolean = false, + /** The time an all-day reminder without one of its own rings at. */ + val allDayMinuteOfDay: Int = DEFAULT_ALL_DAY_REMINDER_MINUTE, val location: String = "", val url: String = "", val tags: List = emptyList(), @@ -124,6 +136,20 @@ class TaskEditViewModel @Inject constructor( /** Sections shown by default (a setting); the rest unfold via [revealField]. */ private var defaultFields: Set = emptySet() + /** The settings as the form was bound, for the defaults an all-day switch swaps in. */ + private var settings: Settings = Settings() + + /** The reminders before the last all-day switch, and what it made of them. */ + private var lastSwitch: Pair, List>? = null + + /** The reminders the task was loaded with, and the dates it had then. */ + private var storedReminders: Set = emptySet() + + /** [storedReminders] as the task's current kind, timed or all-day. */ + private var loadedReminders: List = emptyList() + private var loadedStart: Instant? = null + private var loadedDue: Instant? = null + /** Start a fresh task, optionally pre-selecting a list / parent. */ fun bindNew( presetListId: Long? = null, @@ -138,6 +164,7 @@ class TaskEditViewModel @Inject constructor( baselineLastModified = null viewModelScope.launch { val settings = settingsPrefs.settings.first() + this@TaskEditViewModel.settings = settings defaultFields = settings.defaultEditFields val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() } val preset = presetFilterId?.let { settingsPrefs.savedFilter(it).first() } @@ -157,6 +184,7 @@ class TaskEditViewModel @Inject constructor( loading = false, isNew = true, autofocusTitle = settings.autofocusTitle, + allDayMinuteOfDay = settings.allDayReminderMinuteOfDay, listId = defaultList, // A preset due is all-day, so a default start has to be too, or // today's start would land after a due of today. @@ -174,7 +202,7 @@ class TaskEditViewModel @Inject constructor( parentCandidates = loadParents(defaultList, selfId = null), title = initialTitle.orEmpty(), tags = (listOfNotNull(presetTag) + preset?.tags.orEmpty()).distinct(), - ), + ).withDefaultReminders(), ).also { baseline = it.toForm().copy(title = "") } } } @@ -186,7 +214,9 @@ class TaskEditViewModel @Inject constructor( editingTaskId = taskId editingOccurrence = occurrenceStart viewModelScope.launch { - defaultFields = settingsPrefs.settings.first().defaultEditFields + val settings = settingsPrefs.settings.first() + this@TaskEditViewModel.settings = settings + defaultFields = settings.defaultEditFields val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() } val task = runCatching { repository.taskDetail(taskId, occurrenceStart).first()?.task }.getOrNull() if (task == null) { @@ -194,6 +224,10 @@ class TaskEditViewModel @Inject constructor( return@launch } baselineLastModified = task.lastModified + loadedReminders = repository.remindersFor(taskId).map { it.lead }.distinct() + storedReminders = loadedReminders.toSet() + loadedStart = task.start + loadedDue = task.due _state.value = withFields( TaskEditUiState( loading = false, @@ -208,7 +242,9 @@ class TaskEditViewModel @Inject constructor( priority = task.priority, parentId = task.parentId, percentComplete = task.percentComplete, - reminders = repository.remindersFor(taskId).filter { !it.fromStart }.map { it.minutesBefore }, + reminders = loadedReminders + .sortedWith(taskReminderDisplayOrder(task.isAllDay, settings.allDayReminderMinuteOfDay)), + allDayMinuteOfDay = settings.allDayReminderMinuteOfDay, location = task.location.orEmpty(), url = task.url.orEmpty(), tags = task.tags, @@ -271,35 +307,90 @@ class TaskEditViewModel @Inject constructor( /** Switching list invalidates the parent (it lived in the old list); reload candidates. */ fun onListChange(listId: Long) { if (_state.value.listId == listId) return // re-picking the same list keeps the parent - update { it.copy(listId = listId, parentId = null, errors = emptySet()) } + update { it.copy(listId = listId, parentId = null, errors = emptySet()).withDefaultReminders() } viewModelScope.launch { val candidates = loadParents(listId, selfId = editingTaskId) update { it.copy(parentCandidates = candidates) } } } - fun onStartChange(value: Instant?) = update { it.copy(start = value) } - fun onDueChange(value: Instant?) = update { it.copy(due = value) } + fun onStartChange(value: Instant?) = update { it.copy(start = value).withDefaultReminders() } + fun onDueChange(value: Instant?) = update { it.copy(due = value).withDefaultReminders() } /** * All-day and timed values use different conventions (UTC midnight vs. a real * instant in the device zone), so the switch has to move the timestamps too — * flipping the flag alone makes an all-day task read back as "02:00", which - * looks to the user like the time reset itself. + * looks to the user like the time reset itself. The reminders are made + * the new kind's here, so the editor shows what will be stored; switching + * straight back gives the old ones back. */ fun onAllDayChange(value: Boolean) = update { - it.copy( + if (it.isAllDay == value) return@update it + val switched = it.copy( isAllDay = value, start = it.start?.rebasedForAllDay(value), due = it.due?.rebasedForAllDay(value), ) + if (!it.remindersTouched) { + loadedReminders = carried(loadedReminders, switched) + return@update switched.withDefaultReminders() + } + switched.copy(reminders = carried(it.reminders, switched).sortedForDisplay(switched)) } + + /** [reminders] as [switched]'s kind; straight back after a switch, the ones before it. */ + private fun carried(reminders: List, switched: TaskEditUiState): List { + val previous = lastSwitch + val carried = if (previous != null && previous.second == reminders) { + previous.first + } else { + StoredReminders.switched(reminders, switched.toForm(), settings, nowMillis(), ZoneId.systemDefault()) + } + lastSwitch = reminders to carried + return carried + } + + /** + * The reminders the task will be saved with while the user has not + * touched them: those it was loaded with, plus the defaults of the dates + * it has gained — all of them, for a new task. Shown so they are noted + * before saving rather than added unseen. + */ + private fun TaskEditUiState.withDefaultReminders(): TaskEditUiState { + if (remindersTouched) return this + val reminders = StoredReminders.withDefaults( + toForm().copy(reminders = loadedReminders), + loadedStart, + loadedDue, + settings, + nowMillis(), + ZoneId.systemDefault(), + ).sortedForDisplay(this) + val visible = if (reminders.isEmpty()) visibleFields else visibleFields + TaskFormField.Reminder + return copy( + reminders = reminders, + // A new task's, or defaults added to the ones it had: stored as shown. + remindersChosen = isNew || reminders.toSet() != storedReminders, + visibleFields = visible, + hiddenFields = hiddenOf(visible), + ) + } + + private fun nowMillis(): Long = Clock.System.now().toEpochMilliseconds() fun onPriorityChange(value: Priority) = update { it.copy(priority = value) } fun onPercentChange(value: Int?) = update { it.copy(percentComplete = value?.coerceIn(0, 100)) } fun onParentChange(parentId: Long?) = update { it.copy(parentId = parentId) } - fun addReminder(minutesBeforeDue: Int) = update { - it.copy(reminders = (it.reminders + minutesBeforeDue).distinct().sorted(), errors = emptySet()) + fun addReminder(reminder: TaskReminder) = update { + it.copy( + reminders = (it.reminders + reminder).distinct().sortedForDisplay(it), + remindersTouched = true, + remindersChosen = true, + errors = emptySet(), + ) + } + fun removeReminder(reminder: TaskReminder) = update { + it.copy(reminders = it.reminders - reminder, remindersTouched = true, remindersChosen = true) } - fun removeReminder(minutesBeforeDue: Int) = update { it.copy(reminders = it.reminders - minutesBeforeDue) } fun onLocationChange(value: String) = update { it.copy(location = value) } fun onUrlChange(value: String) = update { it.copy(url = value) } fun addTag(name: String) = update { it.copy(tags = normalizeTags(it.tags + name), tagDraft = "") } @@ -382,6 +473,10 @@ class TaskEditViewModel @Inject constructor( } } +/** The order the editor lists [state]'s reminders in. */ +private fun List.sortedForDisplay(state: TaskEditUiState): List = + sortedWith(taskReminderDisplayOrder(state.isAllDay, state.allDayMinuteOfDay)) + /** Snapshot the editable state as a validated [TaskForm]. */ private fun TaskEditUiState.toForm(): TaskForm = TaskForm( title = title, @@ -394,6 +489,7 @@ private fun TaskEditUiState.toForm(): TaskForm = TaskForm( parentId = parentId, percentComplete = percentComplete, reminders = reminders, + remindersChosen = remindersChosen, location = location.ifBlank { null }, url = url.ifBlank { null }, tags = withDraft(tagDraft), diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/NotificationsScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/NotificationsScreen.kt index c21250a..8728d8c 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/NotificationsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/NotificationsScreen.kt @@ -41,6 +41,8 @@ import androidx.core.net.toUri import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.agendula.data.prefs.SNOOZE_PRESETS import de.jeanlucmakiola.agendula.data.reminders.TaskNotifier +import de.jeanlucmakiola.agendula.domain.TaskReminderOrder +import de.jeanlucmakiola.agendula.domain.asTaskReminder import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.agendula.ui.common.OnResume import de.jeanlucmakiola.agendula.ui.common.canScheduleExactAlarms @@ -48,7 +50,7 @@ import de.jeanlucmakiola.agendula.ui.common.openExactAlarmSettings import de.jeanlucmakiola.agendula.ui.common.ReminderDefaultPicker import de.jeanlucmakiola.agendula.ui.common.TimePickerAlert import de.jeanlucmakiola.agendula.ui.common.formatMinuteOfDay -import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel +import de.jeanlucmakiola.agendula.ui.common.reminderLabel import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.OptionPicker @@ -57,6 +59,7 @@ import de.jeanlucmakiola.floret.components.SelectedCheck import de.jeanlucmakiola.floret.components.pastelize import de.jeanlucmakiola.floret.identity.collapseExit import de.jeanlucmakiola.floret.identity.expandEnter +import de.jeanlucmakiola.floret.reminders.ReminderLead import de.jeanlucmakiola.floret.reminders.ReminderOverride import de.jeanlucmakiola.floret.reminders.reminderOverrideFor import java.time.LocalTime @@ -159,13 +162,13 @@ internal fun NotificationsScreen( } GroupedRow( title = stringResource(R.string.settings_default_reminder), - summary = reminderChoiceLabel(state.settings.defaultReminderMinutes), + summary = reminderChoiceLabel(state.settings.defaultReminders), position = Position.Middle, onClick = { showOffset = true }, ) GroupedRow( title = stringResource(R.string.settings_default_reminder_allday), - summary = reminderChoiceLabel(state.settings.defaultAllDayReminderMinutes, allDay = true), + summary = reminderChoiceLabel(state.settings.defaultAllDayReminders, allDay = true), position = Position.Middle, onClick = { showAllDay = true }, ) @@ -251,7 +254,7 @@ internal fun NotificationsScreen( title = stringResource(R.string.settings_default_reminder), summary = listOverrideSummary( state.settings.perListReminderOverride.reminderOverrideFor(list.id), - state.settings.defaultReminderMinutes, + state.settings.defaultReminders, allDay = false, ), position = Position.Middle, @@ -261,7 +264,7 @@ internal fun NotificationsScreen( title = stringResource(R.string.settings_default_reminder_allday), summary = listOverrideSummary( state.settings.perListAllDayReminderOverride.reminderOverrideFor(list.id), - state.settings.defaultAllDayReminderMinutes, + state.settings.defaultAllDayReminders, allDay = true, ), position = Position.Middle, @@ -284,24 +287,24 @@ internal fun NotificationsScreen( if (showOffset) { ReminderDefaultPicker( title = stringResource(R.string.settings_default_reminder), - selected = state.settings.defaultReminderMinutes.let { - if (it.isEmpty()) ReminderOverride.None else ReminderOverride.Minutes(it) + selected = state.settings.defaultReminders.let { + if (it.isEmpty()) ReminderOverride.None else ReminderOverride.Minutes.of(it) }, allowInherit = false, - onSelect = { viewModel.setDefaultReminderMinutes((it as? ReminderOverride.Minutes)?.minutes.orEmpty()) }, + onSelect = { viewModel.setDefaultReminders((it as? ReminderOverride.Minutes)?.leads.orEmpty()) }, onDismiss = { showOffset = false }, ) } if (showAllDay) { ReminderDefaultPicker( title = stringResource(R.string.settings_default_reminder_allday), - selected = state.settings.defaultAllDayReminderMinutes.let { - if (it.isEmpty()) ReminderOverride.None else ReminderOverride.Minutes(it) + selected = state.settings.defaultAllDayReminders.let { + if (it.isEmpty()) ReminderOverride.None else ReminderOverride.Minutes.of(it) }, allowInherit = false, allDay = true, leadTimeSummary = allDayFiresAt, - onSelect = { viewModel.setDefaultAllDayReminderMinutes((it as? ReminderOverride.Minutes)?.minutes.orEmpty()) }, + onSelect = { viewModel.setDefaultAllDayReminders((it as? ReminderOverride.Minutes)?.leads.orEmpty()) }, onDismiss = { showAllDay = false }, ) } @@ -356,19 +359,27 @@ private data class OverrideTarget(val listId: Long, val allDay: Boolean) /** Row summary for a list: its override, or the inherited global default. */ @Composable -private fun listOverrideSummary(choice: ReminderOverride, globalDefault: List, allDay: Boolean): String = +private fun listOverrideSummary(choice: ReminderOverride, globalDefault: List, allDay: Boolean): String = when (choice) { ReminderOverride.Inherit -> stringResource(R.string.settings_list_reminder_inherits, reminderChoiceLabel(globalDefault, allDay)) ReminderOverride.None -> stringResource(R.string.reminder_none) - is ReminderOverride.Minutes -> reminderChoiceLabel(choice.minutes, allDay) + is ReminderOverride.Minutes -> reminderChoiceLabel(choice.leads, allDay) } -/** "30 minutes before, At due time", or "No reminder" for an empty set. */ +/** + * "30 minutes before, At due time", or "No reminder" for an empty set. Each + * names its date once any counts from the start. + */ @Composable -private fun reminderChoiceLabel(minutes: List, allDay: Boolean = false): String = - if (minutes.isEmpty()) stringResource(R.string.reminder_none) - else minutes.map { reminderLeadTimeLabel(it, allDay) }.joinToString(", ") +private fun reminderChoiceLabel(leads: List, allDay: Boolean = false): String { + if (leads.isEmpty()) return stringResource(R.string.reminder_none) + val reminders = leads.map { it.asTaskReminder() }.sortedWith(TaskReminderOrder) + val anchored = reminders.any { it.fromStart } + return reminders.map { reminder -> + reminderLabel(reminder.minutesBefore, allDay, reminder.fromStart, anchored) + }.joinToString(", ") +} /** SCHEDULE_EXACT_ALARM is user-granted from API 31; below that exact alarms need no grant. */ private fun showExactAlarmRow(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt index 7171ae1..19e4b9a 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt @@ -22,6 +22,7 @@ import de.jeanlucmakiola.agendula.data.tasks.transfer.TransferCounts import de.jeanlucmakiola.agendula.data.tasks.transfer.TransferResult import de.jeanlucmakiola.agendula.domain.TaskFormField import de.jeanlucmakiola.agendula.domain.TaskList +import de.jeanlucmakiola.floret.reminders.ReminderLead import de.jeanlucmakiola.floret.reminders.ReminderOverride import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow @@ -219,12 +220,12 @@ class SettingsViewModel @Inject constructor( fun setThemeMode(mode: ThemeMode) = viewModelScope.launch { prefs.setThemeMode(mode) } fun setDynamicColor(enabled: Boolean) = viewModelScope.launch { prefs.setDynamicColor(enabled) } fun setDefaultList(id: Long?) = viewModelScope.launch { prefs.setDefaultListId(id) } - fun setDefaultReminderMinutes(minutes: List) = viewModelScope.launch { - prefs.setDefaultReminderMinutes(minutes) + fun setDefaultReminders(leads: List) = viewModelScope.launch { + prefs.setDefaultReminders(leads) resyncReminders() } - fun setDefaultAllDayReminderMinutes(minutes: List) = viewModelScope.launch { - prefs.setDefaultAllDayReminderMinutes(minutes) + fun setDefaultAllDayReminders(leads: List) = viewModelScope.launch { + prefs.setDefaultAllDayReminders(leads) resyncReminders() } fun setAllDayReminderMinuteOfDay(minuteOfDay: Int) = viewModelScope.launch { diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index dc5498c..0d2ee73 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -7,8 +7,8 @@ المهام لا مهام هنا بعد. تعذَّر تحميل هذه المهام. - لا تاريخ - مكتمل + بلا موعد + المكتملة المهمة لم تعد هذه المهمة موجودة. تعديل @@ -26,7 +26,7 @@ متأخر اليوم القادم - لا تاريخ + بلا موعد مكتمل لا شئ منخفض @@ -71,7 +71,7 @@ لغة التطبيق افتراضي النظام المساعدة في الترجمة - يتبع النظام + اتباع النظام اللون الديناميكي التذكيرات المستحقة التوقيت الدقيق @@ -106,4 +106,559 @@ افتح متعقب المشكلات نسخ المهام من %1$s مكتمل + مسح + القائمة + طوال اليوم + تعيين + مسح + المزيد من الخانات + إغلاق + إلغاء + لا شئ + مخصص + تعيين + بنفسجي + وردي + القوائم + مهمة جديدة + فرز حسب + الأولوية + العنوان + المُضاف مؤخرًا + تكرارات + مسح المهام المكتملة؟ + + سيتم حذف (%1$d) لا مهام مكتملة في هذه القائمة. + سيتم حذف %1$d مهمة مكتملة في هذه القائمة. + سيتم حذف %1$d مهمتان مكتملتان في هذه القائمة. + سيتم حذف %1$d مهام مكتملة في هذه القائمة. + سيتم حذف %1$d مهمة مكتملة في هذه القائمة. + سيتم حذف %1$d مهمة مكتملة في هذه القائمة. + + حذف + موعد الاستحقاق + مستحق + الأولوية + التقدم + المهام الفرعية + المهمة الأم + جزء من + %1$d%% + يبدأ + مستحق + الأولوية + التقدم + المهمة الأم + لا شيء (المستوى الأعلى) + البحث عن المهام + لا مهام مطابقة في هذه القائمة. + التذكير + عنوان المهمة + المهمة غُيِّ‏‏رت في مكان آخر + حُدِّثت هذه المهمة في مكان ما آخر (مزامنة أو تطبيق آخر) منذ قمت بفتحها. الكتابة فوق هذه التغييرات بتعديلاتك؟ + استبدال + في وقت المهمة + في اليوم + اختر مهمة لرؤيتها هنا + مخصص (%1$s) + أدخل قيمةً + استخدام الافتراضي + دقائق + ساعات + أيام + أسابيع + التذكيرات لكل قائمة + تجاوز التذكير الافتراضي للقوائم الفردية + اسحب لإعادة الترتيب + الصوت، الاهتزاز و الأهمية لتذكيرات هذه القائمة + اليوم + اليوم + اليوم + التكرار + كل + كل يوم + كل أسبوع + كل شهر + كل سنة + + كل %d ساعة + كل ساعة + كل ساعتان + كل %d ساعات + كل %d ساعة + كل %d ساعة + + + كل %1$d يوم + كل %1$d يوم + كل %1$d يومان + كل %1$d أيام + كل %1$d يوم + كل %1$d يوم + + + كل %1$d أسبوع + كل %1$d أسبوع + كل %1$d أسبوعان + كل %1$d أسابيع + كل %1$d أسبوع + كل %1$d أسبوع + + + كل %1$d شهر + كل %1$d شهر + كل %1$d شهران + كل %1$d أشهر + كل %1$d شهر + كل %1$d شهر + + + كل %1$d سنة + كل %1$d سنة + كل %1$d سنتين + كل %1$d سنوات + كل %1$d سنة + كل %1$d سنة + + + كل %d دقيقة + كل %d دقيقة + كل %d دقيقتان + كل %d دقائق + كل %d دقيقة + كل %d دقيقة + + القوائم + القادمة + الكل + المتأخرة + غدًا + مخصص + أيام + أسابيع + شهور + سنوات + ينتهي + أبدًا + مرّات + التكرار + الموقع + الرابط + https://… + تجاهل + %1$d/%2$d + ملغي + تكرار + اللون + بنفسجي باهت + أحمر + برتقالي + أصفر كهرماني + زيتوني + أخضر + أخضر مزرق + سماوي + أزرق + نيلي + غدًا + يوم أمس + تم + شخصي + متابعة + إنهاء + تبديل + نسخ + جارٍ التصدير… + فتح + التخزين + جارٍ الاستيراد… + استيراد + جارٍ الإزالة… + تم + %1$s + إعادة المحاولة + https://cloud.example.com + متابعة + مهمة جديدة + عرض الكل + ابحث عن المهام + مسح البحث + تذكيرات المهمة + تفعيل التذكيرات + ليس الآن + Agendula + مهمة متكرّرة + أدخل رقمًا من ١ إلى ٩٩٩ + التالي: %1$s + هذه القاعدة لا تتكرر أبدًا + في موعد + بعد عدد من المرّات + تجاهل التغييرات؟ + متابعة التعديل + إضافة تذكير + إزالة التذكير + حذف المهمة؟ + قيد التقدم + المزيد من الخيارات + إلغاء المهمة + استعادة المهمة + قائمة جديدة + قائمة جديدة + تعديل القائمة + اسم القائمة + حذف القائمة + حذف القائمة؟ + ليس الآن + إنشاء قائمة + التراخيص مفتوحة المصدر + جارٍ المزامنة + كلمة المرور + للقراءة فقط + مهام + الأولوية + حذف + اليوم + متى + تقرير العطل + استيراد المهام + الملف المُختار + استيراد إلى + المهام المستوردة + إزالة الحساب + لم تتم المزامنة مطلقًا + آخر مزامنة + حساب CalDAV + زامن الآن + عنوان البريد الإلكتروني + تسجيل الدخول + جارٍ تسجيل الدخول + تقارير المزامنة + فهمت + جارٍ مزامنة المهام + عنوان الخادم + البدء من جديد + اسم المستخدم + تسجيل الدخول + حاول مرة أخرى + إضافة حساب + الوسوم + الوسوم + الوسوم + أضف وسمًا + لا شيء مستحق اليوم 🎉 + + (%d) لا دقائق + %d دقيقة واحده + %d دقيقتان + %d دقائق + %d دقيقة + %d دقيقة + + + (%1$d) لا دقائق قبل + %1$d دقيقة واحده قبل + %1$d دقيقتان قبل + %1$d دقائق قبل + %1$d دقيقة قبل + %1$d دقيقة قبل + + + في %1$d ساعة + في %1$d ساعة + في %1$d ساعتان + في %1$d ساعات + في %1$d ساعة + في %1$d ساعة + + + في %1$d يوم + في %1$d يوم + في %1$d يومان + في %1$d أيام + في %1$d يوم + في %1$d يوم + + + (%d) لا ساعات + %d ساعة واحده + %d ساعتان + %d ساعات + %d ساعة + %d ساعة + + + (%1$d) لا ساعات قبل + %1$d ساعة واحده قبل + %1$d ساعتان قبل + %1$d ساعات قبل + %1$d ساعة قبل + %1$d ساعة قبل + + + متأخر %1$d ساعة + متأخر %1$d ساعة + متأخر %1$d ساعتان + متأخر %1$d ساعات + متأخر %1$d ساعة + متأخر %1$d ساعة + + + متأخر %1$d يوم + متأخر %1$d يوم + متأخر %1$d يومان + متأخر %1$d أيام + متأخر %1$d يوم + متأخر %1$d يوم + + قريباً + غدًا + ثم + لا شئ مستحق + جميع القوائم + لا شيء مستحق اليوم + + (%1$d) لا أيام قبل + %1$d يوم واحد قبل + %1$d يومان قبل + %1$d أيام قبل + %1$d يوم قبل + %1$d يوم قبل + + الاستحقاق لا يمكنه أن يكون قبل البدء. + الاستحقاق + متأخرة + مستحق %1$s + أي موعد استحقاق + الفرز و التصفية + مسح عامل التصفية + عوامل التصفية + عامل تصفية جديد + تعديل عامل تصفية + اسم عامل التصفية + أي قائمة + أي وسم، أو بلا + أي أولوية + منخفض وأعلى + متوسط وأعلى + عالي فقط + حذف ”%1$s“؟ + حذف عامل التصفية + تم حذف عامل التصفية + تصفية حسب الوسم + القوائم + السمة، اللون و تنسيق الوقت + الحقول، القائمة والمهام الفرعية الافتراضية + %1$s في %2$s + %1$s حتى %2$s + + %1$s، %2$d مرة + %1$s، %2$d مرة + %1$s، %2$d مرتان + %1$s، %2$d مرّات + %1$s، %2$d مرة + %1$s، %2$d مرة + + لا يتكرر + فقط هذه المهمة + هذه والمهام اللاحقة + جميع المهام في السلسلة + أدخل عنوانًا. + اختر قائمة. + تحتاج مهمة متكررة لموعد بدء أو استحقاق. + لا يتكرر + إزالة الوسم %1$s + لم يتم حفظ تغييراتك لهذه المهمة. + حفظ التغييرات لمهمة متكررة + أضف تذكيرًا (بخلاف ذلك الافتراضي ينطبق) + %1$d / %2$d + سيتم حذف هذه المهمة. + + سيتم حذف هذه المهمة و (%1$d) لا مهام الفرعية خاصّتها. + سيتم حذف هذه المهمة و المهمة الفرعية خاصّتها. + سيتم حذف هذه المهمة و المهمتان الفرعيتان خاصّتها. + سيتم حذف هذه المهمة و الـ %1$d مهام الفرعية خاصّتها. + سيتم حذف هذه المهمة و %1$d المهمة الفرعية خاصّتها. + سيتم حذف هذه المهمة و %1$d المهمة الفرعية خاصّتها. + + حذف المهمة المتكررة + اجمع بين مواعيد الاستحقاق، القوائم، الوسوم والأولوية إلى طريقة عرض من خاصّتك + تحتاج مهمة إلى إحدى الوسوم التي اخترتها + مطابقة كل الوسوم + انتهى كل شيء 🎉 + تعذّر قراءة مهامك. + تعذّر حفظ القائمة. + تعذّر حذف القائمة. + تعذّر نسخ المهام. لا شيء تغيّر — لم يتم المساس بالنسخ الأصلية خاصّتك. + تعذّر نقل مهامك السابقة + تعذّر كتابة التصدير + تعذّر فتح المجلد المُختار + تعذّر إنشاء ملف في المجلد المُختار + تعذّر قراءة الملف كـ iCalendar + تعذّر حفظ المهام + تعذّر حفظ الحساب. + تعذّر الوصول للخادم. حاول مرة أخرى عندما تعود متصلًا. + تتم مشاركة هذه القائمة معك للقراءة فقط. + لا يسمح هذا الحساب التطبيقات بإنشاء قوائم مهام. + لا تزال محفوظة ولا شئ فُقد. اضغط لمحاولة نقلهم مرة أخرى. + المجلد المختار غير قابل للكتابة + فُقد الوصول إلى الموقع المُختار + الملف لا يحتوي مهام + لم تنتهي آخر مزامنة + فشلت المزامنة — لم يقبل الخادم كلمة المرور + فشلت المزامنة — تعذّر الوصول للخادم + فشلت المزامنة — شهادة الخادم غير موثوقة + فشلت المزامنة — أبلغ الخادم عن خطأ + فشلت المزامنة — أبلغ الخادم عن خطأ (%1$d) + فشلت المزامنة — الحساب غير مكتمل؛ أزله وأضفه مجددًا + لم تنتهي عملية تسجيل الدخول + لم ينتهي الخادم تسجيل دخولك. حاول مجددًا بعد لحظة. + تصل التغييرات فورًا + أي خادم CalDAV + خادم CalDAV آخر + تم مُشارك معك + أين مهامك؟ + ما عنوانك هناك؟ + أين مهامك؟ + جاري قراءة قوائم المهام خاصّتك + جاري الانتظار لمتصفحك + استخدم كلمة مرور بدلاً + فتح صفحة تسجيل الدخول + أيّ القوائم يجب المزامنة؟ + لا دعم للمهام عبر CalDAV + جارٍ البحث عن خادم CalDAV + يحتاج iCloud كلمة مرور خاصة بتطبيق + يحتاج Fastmail كلمة مرور تطبيق + اختر على الأقل قائمة واحدة + تم إعداد هذا الحساب بالفعل. + لن يقوم هذا الجهاز بتخزين كلمة المرور. + تحقق من هذا قبل تسجيل الدخول + لم يتم قبول اسم المستخدم أو كلمة المرور هذه. + + يزامن %1$d قائمة + يزامن %1$d قائمة + يزامن %1$d قائمتان + يزامن %1$d قوائم + يزامن %1$d قائمة + يزامن %1$d قائمة + + + فوري لـ %1$d من (%2$d) لا قوائم + فوري لـ %1$d من %2$d قائمة + فوري لـ %1$d من %2$d قائمتان + فوري لـ %1$d من %2$d قوائم + فوري لـ %1$d من %2$d قائمة + فوري لـ %1$d من %2$d قائمة + + اختر الخدمة التي تعيش عليها قوائم مهامك. يتزامن Agendula مع أي شيء يدعم CalDAV. + عنوان البريد الإلكتروني الذي تستخدمه مع هذه الخدمة. يعثر Agendula على الخادم بنفسه. + ستبقي Agendula هذه القوائم في مزامنة من الآن فصاعدًا. يمكنك تغيير ما يُزامن في الإعدادات. + أدخل عنوان الخادم CalDAV خاصّتك. عنوان بريد إلكتروني يعمل أيضًا، إذا كان الخادم ينشر نفسه تحت نطاقه. + استخدم كلمة مرور تطبيق إذا كان المزود يقدم واحدة — يمكن إلغاؤها دون تغيير كلمة مرور حسابك. + اختر قوائم المهام للإبقاء في مزامنة. يمكنك تغيير هذا لاحقًا. + هذا لا يبدو كعنوان ويب. جرّب شيئًا مثل cloud.example.com. + استجاب هذا العنوان، لكنه ليس خادم CalDAV. إذا قمت بلصق موقع الويب، فجرّب عنوان الخادم الذي يوفره المزود للتقويمات. + لا يوفر هذا الخادم تقويمات. تحقق من العنوان مع مزودك. + تعذّر الوصول لهذا الخادم. تحقق من العنوان واتصالك. + هذا العنوان غير مشفر. يرسل Agendula كلمة مرورك فقط عبر https. + سُجِّل الدخول، لكن هذا الحساب لا يحتوي قوائم مهام. + واجه الخادم مشكلة. حاول مجددًا بعد لحظة. + جارٍ إضافة الحساب + لا يدعم تقويم Google المهام عبر CalDAV — كما هو مذكور في مستنداته الخاصة — لذلك Agendula لا يمكنه المزامنة معه. + يحتفظ هذا الخادم ببعض قوائم المهام على %1$s، التي لا يمكن لـ Agendula تسجيل الدخول إليها بعد. + سُجِّل الدخول، لكن هذا الحساب لا يحتوي قوائم مهام يمكن أن يستخدمها Agendula. + تم إغلاق نافذة الموافقة قبل أن يرد الخادم. + يرفض الخادم المحاولات المتكررة. انتظر بضع دقائق وحاول مرة أخرى. + الخادم في وضع الصيانة. حاول مرة أخرى بمجرد عودته. + لا يحتوي هذا الجهاز على متصفح يمكنه فتح صفحة تسجيل الدخول. استخدم كلمة مرور بدلاً. + يحتاج Fastmail كلمة مرور تطبيق، ليس كلمة مرور حسابك — وCalDAV ليس على الخطة الأساسية. + يحتاج iCloud كلمة مرور خاصة بتطبيق، التي يمكنك إنشاؤها في appleid.apple.com مع تفعيل المصادقة الثنائية. + ستقوم بذلك في متصفحك، ثم العودة هنا لإكمال. + لن تظهر المهام المحفوظة في iCloud في تطبيق تذكيرات Apple. + لا يتم تضمين CalDAV في الخطة الأساسية لـ Fastmail. + لا تدعم تقويم Google المهام عبر CalDAV. + استخدم كلمة مرور تطبيق إذا كان خادمك يوفر واحدة — يمكن إلغاؤها دون تغيير كلمة مرور حسابك. + وافق على Agendula في المتصفح، ثم ارجع هنا. لا يتم إرسال كلمة مرورك مطلقًا إلى هذا التطبيق — يصدر الخادم كلمة مرور تطبيق منفصلة يمكنك إلغاؤها في أي وقت. + صفحة تسجيل الدخول هذه غير مشفرة (http). أي شيء تكتبه هناك، بما في ذلك كلمة مرور حسابك، ينتقل بشكل غير مشفر. + الخادم أرسلنا إلى %1$s، لكنك كتبت %2$s. يعني ذلك عادةً أن إعداد overwrite.cli.url خاصّته غير صحيح. + أبلغ الخادم عن عنوانه كـ %1$s، الذي لا يستطيع هذا الجهاز الوصول، لذلك تم استخدام %2$s بدلاً. من المحتمل أن يكون إعداد overwrite.cli.url أو trusted_proxies خاصّته غير صحيح. + المشاريع التي بنيت ‌‌Agendula عليها + يتضمن Agendula العمل أدناه. اضغط على أي إدخال للحصول على شفرة المصدر الخاصة به. + يتم تضمين بعض من هذا الكود في شكل معدل. أين يكون هذا هو الحال، يتم إدراج كل تغيير في مستودع المصدر المرتبط أعلاه. + اختر ملفًا + اختر ملف آخر + اِسترد ملف آخر + جارٍ قراءة الملف… + + (%1$d) لا مهام + %1$d مهمة + %1$d مهمتان + %1$d مهام + %1$d مهمة + %1$d مهمة + + + تم استبعاد (%1$d) لا أحداث تقويم + تم استبعاد %1$d حدث تقويم + تم استبعاد %1$d حدثان تقويم + تم استبعاد %1$d أحداث تقويم + تم استبعاد %1$d حدث تقويم + تم استبعاد %1$d حدث تقويم + + قائمة جديدة: %1$s + قائمة جديدة على هذا الجهاز + + تم استيراد (%1$d) لا مهام إلى %2$s + تم استيراد %1$d مهمة إلى %2$s + تم استيراد %1$d مهمتان إلى %2$s + تم استيراد %1$d مهام إلى %2$s + تم استيراد %1$d مهمة إلى %2$s + تم استيراد %1$d مهمة إلى %2$s + + + %1$d موجود بالفعل، تم تخطيه + %1$d موجودة بالفعل، تم تخطيها + %1$d موجودتان بالفعل، تم تخطيهما + %1$d موجودين بالفعل، تم تخطيهم + %1$d موجودة بالفعل، تم تخطيهم + %1$d موجودة بالفعل، تم تخطيهم + + تأجيل %1$d دقيقة + الإشعارات للمهام التي حان موعدها. + يقوم Agendula بإشعارك عندما يحين موعد شيء ما، لذلك لا داعٍ لأن تحتفظ بأي شيء في رأسك. + + (%1$d) لا أسابيع قبل + %1$d أسبوع واحد قبل + %1$d أسبوعان قبل + %1$d أسابيع قبل + %1$d أسبوع قبل + %1$d أسبوع قبل + + كل مهمة مع موعد استحقاق تحصل على إشعارها الخاص. + إيقاف تشغيل التذكيرات وقتما تشاء — أنه فقط مفتاح تبديل. + لا قوائم مهام بعد. + لا حسابات حتى الآن + مهمة تحتاج كل وسم اخترته + فقط عامل التصفية تذهب. تظل مهامه أينما هي. + على آخر اطلاع بالكل + لا قوائم للعرض + لاحقة + بلا موعد + القوائم الذكية + إظهار في القوائم الذكية + تضمين مهام هذه القائمة في اليوم، القادمة، الكل و القوائم الذكية الأخرى + مهام اليوم، أو أي قائمة، بضغطة لوضع علامة صح عليهم + لا شئ متبقى للقيام، استمتع به + اتباع النظام: %1$s + + تم نسخ (%1$d) لا مهام إلى قائمة %2$d. اختر ”على هذا الجهاز“ أعلاه لرؤيتها. + تم نسخ %1$d مهمة إلى قائمة %2$d. اختر ”على هذا الجهاز“ أعلاه لرؤيتها. + تم نسخ %1$d مهمتان إلى قائمتان %2$d. اختر ”على هذا الجهاز“ أعلاه لرؤيتهما. + تم نسخ %1$d مهام إلى قوائم %2$d. اختر ”على هذا الجهاز“ أعلاه لرؤيتهم. + تم نسخ %1$d مهمة إلى قائمة %2$d. اختر ”على هذا الجهاز“ أعلاه لرؤيتهم. + تم نسخ %1$d مهمة إلى قائمة %2$d. اختر ”على هذا الجهاز“ أعلاه لرؤيتهم. + + لا مهام مطابقة لـ ”%1$s“ + الخادم لن يقبل هذا التغيير. + سيتم حذف ”%1$s“ وجميع مهامه. هذا لا يمكن التراجع عنه. + إنشاء قائمة diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml new file mode 100644 index 0000000..eb37806 --- /dev/null +++ b/app/src/main/res/values-cs/strings.xml @@ -0,0 +1,118 @@ + + + Agendula + Zpět + Uložit + Smazat + Úkoly + Dokončené + Úkol + Tento úkol již neexistuje. + Upravit + Popis + Nemohu uložit. Zkuste to znovu. + Přidat úkol + Zatím zde nejsou žádné úkoly. + Tyto úkoly se nepodařilo načíst. + Přidat dílčí úkol + Přidat úkol + Hotovo + Po termínu + Po termínu + Po termínu + Na dnešek žádné úkoly 🎉 + Na dnešek žádné úkoly + Hledat úkoly + Hledat úkoly + Nic nenalezeno pro “%1$s” + Bez termínu + Bez termínu + Tento úkol byl mezitím změněn jinde (například v jiné aplikaci nebo při synchronizaci). Chcete tyto změny přepsat svými úpravami? + Pro každý úkol s termínem splnění se vytvoří samostatné oznámení. + Celý den + Čas upozornění u celodenních úkolů + Celodenní úkoly + Změny tohoto úkolu nebyly uloženy. + Tagy + Přidat tag + Tagy + Tagy + Odkaz + Poloha + Priorita + Žádná + Nízká + Střední + Vysoká + Priorita + Priorita + Priorita + Jen vysoká + Další pole + Pole, která se zobrazí při vytváření nového úkolu. Ostatní jsou dostupná pod položkou „Další pole“. + Stav dokončení + Stav dokončení + Zahodit změny? + Zahodit + Pokračovat v úpravách + Ochrana soukromí + Licence + Licence open source + Licence open source + Agendula obsahuje níže uvedené knihovny a další komponenty. Klepnutím na kteroukoli položku zobrazíte její zdrojový kód. + Zdrojový kód + Nahlásit problém + Otevřít systém pro hlášení chyb + Jazyk aplikace + Podle systému + Přidejte nový jazyk nebo vylepšete překlad na Weblate + Pomozte s překladem + Rozhodli jste se ukládat úkoly v jiné aplikaci, ale nemáte nainstalovanou žádnou kompatibilní aplikaci. Nainstalujte OpenTasks nebo tasks.org, případně přepněte zpět na vlastní úložiště aplikace Agendula. + Pokud nemáte účet, vaše seznamy jsou uložené pouze v tomto zařízení. Kdykoli je můžete exportovat jako soubory iCalendar, které lze otevřít v jiných aplikacích pro úkoly nebo kalendář. Stačí přejít do Nastavení → Úložiště → Exportovat úkoly. + Používat úložiště tohoto zařízení + Úložiště + Kde jsou úkoly uložené a jak je exportovat + Vlastní úložiště aplikace Agendula. Není třeba nic dalšího instalovat. + Uložit do adresáře + Uložit jako ZIP soubor + Exportovat úkoly + Podpora vývoje + Vzhled + Motiv, barva a formát času + Motiv + Dle systému + Světlý + Tmavý + Použije pro widget barvu pozadí aktuálního motivu + Motiv + Podle aplikace + Světlý + Tmavý + Dynamické barvy + Používat barvy z tapety + Formát času + Automaticky + Podle systému: %1$s + 12hodinový (2:00 PM) + 24hodinový formát (14:00) + Víkend začíná v + Automaticky + Vzhled a chování + To nevypadá jako webová adresa. Zkuste zadat něco jako cloud.example.com. + Přihlašovací stránka se nachází na vašem serveru, ne v aplikaci Agendula. Než na ní zadáte heslo ke svému účtu, raději si ověřte, že jí můžete důvěřovat. + Vyhledávání CalDAV serveru + Nový seznam + Nový seznam + Název seznamu + Tento sdílený seznam je pouze pro čtení. Jeho název a barvu může měnit jen vlastník. + Seznamy vám pomohou udržet úkoly přehledně oddělené. Začněte s jedním – další můžete přidávat a tento kdykoli přejmenovat. + Importované úkoly + Kde + Jen v tomto zařízení + Zkopírujte je do vlastního úložiště Agenduly. Jde o jednorázovou kopii – originály zůstanou tam, kde jsou. + Kde jsou vaše úkoly? + Kde jsou vaše úkoly? + Barva + Barva řádků + Každý řádek je lehce zabarven barvou svého seznamu + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 33517ea..f1f1cf2 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -78,7 +78,6 @@ Gebe einen Titel ein. Wähle eine Liste. Fälligkeit darf nicht vor dem Start liegen. - Setze ein Fälligkeits-Datum für Erinnerungen. Listen Neue Aufgabe Deine Aufgaben konnten nicht gelesen werden. @@ -432,4 +431,15 @@ Agendulas eigener Speicher. Nichts weiteres zu installieren nötig. Eine andere Aufgaben-App Erlaubnis verweigert + Woche + Listen + Alle Listen + Morgen + Sortieren und Filter + Filtern nach Tag + Filter zurücksetzen + Endet + Alle Termine der Serie + Wiederholung + Wiederholt sich nicht diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 15dd7bb..390cee9 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -108,7 +108,6 @@ Veuillez saisir un titre. Sélectionnez une liste. La date d\'échéance ne peut pas être antérieure au début. - Fixez une date d\'échéance pour pourvoir définir un rappel. Listes Nouvelle tâche Impossible de lire vos tâches. diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 080fdcd..044a1c3 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -79,7 +79,6 @@ Insira um título. Escolha uma lista. Prazo não pode ser antes do início. - Ajuste um prazo para usar um lembrete. Listas Nova tarefa Não foi possível ler suas tarefas. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c64bd5e..fc84625 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -181,7 +181,7 @@ Enter a title. Choose a list. Due can\'t be before the start. - Set a due date to use a reminder. + Set a start or due date to use a reminder. A repeating task needs a start or due date. Repeat Does not repeat @@ -199,7 +199,15 @@ Add reminder Add a reminder (otherwise the default applies) Remove reminder - %1$s (before start) + At start time + At due time + On the start date + On the due date + + %1$s at %2$s + From start + From due date + Time %1$d / %2$d %1$d/%2$d Delete task? @@ -596,6 +604,38 @@ %1$d week before %1$d weeks before + + %1$d minute before the start + %1$d minutes before the start + + + %1$d hour before the start + %1$d hours before the start + + + %1$d day before the start + %1$d days before the start + + + %1$d week before the start + %1$d weeks before the start + + + %1$d minute before the due date + %1$d minutes before the due date + + + %1$d hour before the due date + %1$d hours before the due date + + + %1$d day before the due date + %1$d days before the due date + + + %1$d week before the due date + %1$d weeks before the due date + Accounts diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/reminders/ReminderPlannerTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/reminders/ReminderPlannerTest.kt index 7e761ba..9d9ad13 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/data/reminders/ReminderPlannerTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/reminders/ReminderPlannerTest.kt @@ -2,9 +2,11 @@ package de.jeanlucmakiola.agendula.data.reminders import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.agendula.data.prefs.Settings -import de.jeanlucmakiola.agendula.data.tasks.TaskReminder +import de.jeanlucmakiola.agendula.domain.TaskReminder import de.jeanlucmakiola.agendula.domain.allDayInstantOf import de.jeanlucmakiola.agendula.domain.testTask +import de.jeanlucmakiola.agendula.domain.asReminderLead +import de.jeanlucmakiola.floret.reminders.asReminderLeads import org.junit.jupiter.api.Test import java.time.LocalDate import java.time.LocalDateTime @@ -38,6 +40,28 @@ class ReminderPlannerTest { assertThat(trigger).isEqualTo(millis(date.minusDays(1).atTime(8, 30), berlin)) } + @Test + fun `a default from before the task existed does not ring`() { + val created = Instant.fromEpochMilliseconds(millis(date.atTime(14, 0), berlin)) + val task = testTask(id = 1, due = allDayInstantOf(date), isAllDay = true, created = created) + val plan = ReminderPlanner.plan( + tasks = listOf(task), + perTask = emptyMap(), + settings = Settings(allDayReminderMinuteOfDay = 9 * 60), + now = created.toEpochMilliseconds(), + zone = berlin, + ) + assertThat(plan).isEmpty() + val own = ReminderPlanner.plan( + tasks = listOf(task), + perTask = mapOf(1L to listOf(TaskReminder(0))), + settings = Settings(allDayReminderMinuteOfDay = 9 * 60), + now = created.toEpochMilliseconds(), + zone = berlin, + ) + assertThat(own).hasSize(1) + } + @Test fun `a timed reminder counts back from the instant itself`() { val due = Instant.fromEpochMilliseconds(1_000_000_000L) @@ -60,6 +84,59 @@ class ReminderPlannerTest { assertThat(plan).containsExactly(ScheduledReminder(1, millis(date.atTime(7, 0), berlin))) } + @Test + fun `a reminder another client already dismissed is not fired again`() { + val due = Instant.parse("2026-07-20T10:00:00Z") + val task = testTask(id = 3, due = due) + val trigger = due.toEpochMilliseconds() - 15 * 60_000L + // Inside the missed-reminder grace, which is where a dismissal elsewhere matters. + val now = trigger + 60_000L + fun plan(acknowledged: Instant?) = ReminderPlanner.plan( + tasks = listOf(task), + perTask = mapOf(3L to listOf(TaskReminder(15, acknowledgedAt = acknowledged))), + settings = Settings(), + now = now, + zone = berlin, + ) + + assertThat(plan(acknowledged = Instant.fromEpochMilliseconds(trigger + 1))).isEmpty() + // Dismissed before this trigger came due: an earlier one, still to fire. + assertThat(plan(acknowledged = Instant.fromEpochMilliseconds(trigger - 1))) + .containsExactly(ScheduledReminder(3, trigger)) + assertThat(plan(acknowledged = null)).containsExactly(ScheduledReminder(3, trigger)) + } + + @Test + fun `a task whose alarms were turned off elsewhere has none, not the default`() { + val due = Instant.parse("2026-07-20T10:00:00Z") + val now = due.toEpochMilliseconds() - 60 * 60_000L + fun plan(perTask: Map>) = ReminderPlanner.plan( + tasks = listOf(testTask(id = 4, due = due)), + perTask = perTask, + settings = Settings(defaultReminders = listOf(15).asReminderLeads()), + now = now, + zone = berlin, + ) + + assertThat(plan(mapOf(4L to emptyList()))).isEmpty() + assertThat(plan(emptyMap())).hasSize(1) + } + + @Test + fun `an all-day reminder dismissed elsewhere before this device's hour stays dismissed`() { + val task = testTask(id = 5, due = allDayInstantOf(date)).copy(isAllDay = true) + // Rung at midnight on the Mac, dismissed at 07:00; this device rings at 09:00. + val dismissed = date.atTime(7, 0).atZone(berlin).toInstant() + val plan = ReminderPlanner.plan( + tasks = listOf(task), + perTask = mapOf(5L to listOf(TaskReminder(0, acknowledgedAt = Instant.fromEpochMilliseconds(dismissed.toEpochMilli())))), + settings = Settings(allDayReminderMinuteOfDay = 9 * 60), + now = millis(date.atTime(8, 0), berlin), + zone = berlin, + ) + assertThat(plan).isEmpty() + } + @Test fun `an all-day task takes the all-day defaults, not the timed ones`() { val task = testTask(id = 6, due = allDayInstantOf(date)).copy(isAllDay = true, listId = 7) @@ -67,8 +144,8 @@ class ReminderPlannerTest { tasks = listOf(task), perTask = emptyMap(), settings = Settings( - defaultReminderMinutes = listOf(5), - defaultAllDayReminderMinutes = listOf(0, 24 * 60), + defaultReminders = listOf(5).asReminderLeads(), + defaultAllDayReminders = listOf(0, 24 * 60).asReminderLeads(), ), now = millis(date.minusDays(2).atTime(12, 0), berlin), zone = berlin, @@ -152,7 +229,7 @@ class ReminderPlannerTest { val plan = ReminderPlanner.plan( tasks = listOf(task), perTask = mapOf(4L to listOf(TaskReminder(0), TaskReminder(60))), - settings = Settings(defaultReminderMinutes = listOf(15)), + settings = Settings(defaultReminders = listOf(15).asReminderLeads()), now = millis(date.atTime(0, 0), berlin), zone = berlin, ) @@ -169,7 +246,7 @@ class ReminderPlannerTest { val plan = ReminderPlanner.plan( tasks = listOf(task), perTask = emptyMap(), - settings = Settings(defaultReminderMinutes = listOf(5, 30)), + settings = Settings(defaultReminders = listOf(5, 30).asReminderLeads()), now = millis(date.atTime(0, 0), berlin), zone = berlin, ) @@ -186,4 +263,65 @@ class ReminderPlannerTest { val plan = ReminderPlanner.plan(listOf(task), emptyMap(), Settings(), millis(date.atTime(0, 0), berlin), berlin) assertThat(plan.single().occurrenceStart).isEqualTo(due.toEpochMilliseconds()) } + + @Test + fun `an all-day reminder with a time of its own rings at it`() { + val nine = TaskReminder.allDay(daysBefore = 0, minuteOfDay = 9 * 60) + val fivePmBefore = TaskReminder.allDay(daysBefore = 1, minuteOfDay = 17 * 60) + assertThat(ReminderPlanner.triggerAt(allDayInstantOf(date), true, nine.minutesBefore, 7 * 60, berlin)) + .isEqualTo(millis(date.atTime(9, 0), berlin)) + assertThat(ReminderPlanner.triggerAt(allDayInstantOf(date), true, fivePmBefore.minutesBefore, 7 * 60, berlin)) + .isEqualTo(millis(date.minusDays(1).atTime(17, 0), berlin)) + } + + @Test + fun `several times on one all-day task each ring`() { + val task = testTask(id = 6, due = allDayInstantOf(date)).copy(isAllDay = true) + val reminders = listOf(TaskReminder.allDay(0, 9 * 60), TaskReminder.allDay(0, 17 * 60)) + val plan = ReminderPlanner.plan(listOf(task), mapOf(6L to reminders), Settings(), millis(date.atTime(0, 0), berlin), berlin) + assertThat(plan).containsExactly( + ScheduledReminder(6, millis(date.atTime(9, 0), berlin)), + ScheduledReminder(6, millis(date.atTime(17, 0), berlin)), + ) + } + + @Test + fun `a default counts only from a date the task has`() { + val start = Instant.fromEpochMilliseconds(millis(date.atTime(9, 0), berlin)) + val due = Instant.fromEpochMilliseconds(millis(date.atTime(18, 0), berlin)) + val both = testTask(id = 7, due = due).copy(start = start) + val dueOnly = testTask(id = 8, due = due) + val startOnly = testTask(id = 9).copy(start = start) + val defaults = listOf(TaskReminder(60, fromStart = true), TaskReminder(0)).map { it.asReminderLead() } + val now = millis(date.atTime(0, 0), berlin) + + val plan = ReminderPlanner.plan( + listOf(both, dueOnly, startOnly), + emptyMap(), + Settings(defaultReminders = defaults), + now, + berlin, + ) + + assertThat(plan).containsExactly( + ScheduledReminder(7, millis(date.atTime(8, 0), berlin)), + ScheduledReminder(7, due.toEpochMilliseconds()), + ScheduledReminder(8, due.toEpochMilliseconds()), + ScheduledReminder(9, millis(date.atTime(8, 0), berlin)), + ) + } + + @Test + fun `a task's own reminder counts from the other date when its own is missing`() { + val start = Instant.fromEpochMilliseconds(millis(date.atTime(9, 0), berlin)) + val task = testTask(id = 10).copy(start = start) + val plan = ReminderPlanner.plan( + listOf(task), + mapOf(10L to listOf(TaskReminder(15))), + Settings(), + millis(date.atTime(0, 0), berlin), + berlin, + ) + assertThat(plan).containsExactly(ScheduledReminder(10, millis(date.atTime(8, 45), berlin))) + } } diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/StoredRemindersTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/StoredRemindersTest.kt new file mode 100644 index 0000000..ed6392c --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/StoredRemindersTest.kt @@ -0,0 +1,116 @@ +package de.jeanlucmakiola.agendula.data.tasks + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.agendula.data.prefs.Settings +import de.jeanlucmakiola.agendula.domain.TaskForm +import de.jeanlucmakiola.agendula.domain.TaskReminder +import de.jeanlucmakiola.agendula.domain.allDayInstantOf +import de.jeanlucmakiola.agendula.domain.testTask +import de.jeanlucmakiola.agendula.domain.asReminderLead +import de.jeanlucmakiola.floret.reminders.asReminderLeads +import org.junit.jupiter.api.Test +import java.time.LocalDate +import java.time.ZoneId +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Instant + +class StoredRemindersTest { + + private val zone = ZoneId.of("Europe/Berlin") + private val now = Instant.parse("2026-10-01T10:00:00Z") + private val dayBeforeStart = TaskReminder(1_440, fromStart = true) + private val dayBeforeDue = TaskReminder(1_440) + private val hourBeforeDue = TaskReminder(60) + private val settings = Settings( + defaultReminders = listOf(dayBeforeStart, dayBeforeDue, hourBeforeDue).map { it.asReminderLead() }, + ) + + private fun stored(form: TaskForm, current: de.jeanlucmakiola.agendula.domain.Task? = null, with: Settings = settings) = + StoredReminders.of(form, current, with, now.toEpochMilliseconds(), zone) + + @Test + fun `a new task takes the defaults for the dates it has`() { + val form = TaskForm(title = "t", listId = 1, start = now + 2.days, due = now + 5.days) + assertThat(stored(form)).containsExactly(dayBeforeStart, dayBeforeDue, hourBeforeDue) + assertThat(stored(form.copy(start = null))).containsExactly(dayBeforeDue, hourBeforeDue) + assertThat(stored(form.copy(due = null))).containsExactly(dayBeforeStart) + } + + @Test + fun `a default already past when the task gets it is left out`() { + val form = TaskForm(title = "t", listId = 1, start = now, due = now + 5.days) + assertThat(stored(form)).containsExactly(dayBeforeDue, hourBeforeDue) + } + + @Test + fun `a recurring task keeps it for the occurrences to come`() { + val form = TaskForm(title = "t", listId = 1, start = now, due = now + 5.days, rrule = "FREQ=DAILY") + assertThat(stored(form)).containsExactly(dayBeforeStart, dayBeforeDue, hourBeforeDue) + } + + private fun switched(reminders: List, form: TaskForm, with: Settings = settings) = + StoredReminders.switched(reminders, form, with, now.toEpochMilliseconds(), zone) + + @Test + fun `a date the task gains brings its defaults`() { + val current = testTask(start = now + 2.days) + val form = TaskForm(title = "t", listId = 1, start = now + 2.days, due = now + 5.days, reminders = listOf(dayBeforeStart)) + assertThat(stored(form, current)).containsExactly(dayBeforeStart, dayBeforeDue, hourBeforeDue) + val due = testTask(due = now + 5.days) + assertThat(stored(form.copy(reminders = listOf(hourBeforeDue)), due)).containsExactly(hourBeforeDue, dayBeforeStart) + } + + @Test + fun `a gained date the form already counts from keeps the form`() { + val current = testTask(start = now + 2.days) + val ownDue = TaskReminder(30) + val form = TaskForm(title = "t", listId = 1, start = now + 2.days, due = now + 5.days, reminders = listOf(ownDue)) + assertThat(stored(form, current)).containsExactly(ownDue) + } + + @Test + fun `reminders the editor chose are stored as they are`() { + val form = TaskForm(title = "t", listId = 1, start = now + 2.days, due = now + 5.days, remindersChosen = true) + assertThat(stored(form)).isEmpty() + assertThat(stored(form.copy(reminders = listOf(hourBeforeDue)), testTask(due = now + 5.days))) + .containsExactly(hourBeforeDue) + } + + @Test + fun `an edit that keeps its dates keeps the form`() { + val current = testTask(due = now + 5.days) + val form = TaskForm(title = "t", listId = 1, due = now + 6.days) + assertThat(stored(form, current)).isEmpty() + } + + @Test + fun `a default from before anchors existed swaps when the task turns all-day`() { + val legacy = Settings(defaultReminders = listOf(15).asReminderLeads(), defaultAllDayReminders = listOf(0).asReminderLeads()) + val due = allDayInstantOf(LocalDate.of(2026, 10, 3)) + val form = TaskForm(title = "t", listId = 1, due = due, isAllDay = true) + assertThat(switched(listOf(TaskReminder(15)), form, legacy)).containsExactly(TaskReminder(0)) + } + + @Test + fun `only the whole default swaps`() { + val due = allDayInstantOf(LocalDate.of(2026, 10, 3)) + val form = TaskForm(title = "t", listId = 1, due = due, isAllDay = true) + assertThat(switched(listOf(dayBeforeDue), form)).containsExactly(dayBeforeDue) + } + + @Test + fun `a reminder of its own turns into whole days on an all-day task`() { + val due = allDayInstantOf(LocalDate.of(2026, 10, 3)) + val form = TaskForm(title = "t", listId = 1, due = due, isAllDay = true) + assertThat(switched(listOf(TaskReminder(20 * 60)), form)).containsExactly(TaskReminder(1_440)) + } + + @Test + fun `a time of day is dropped when the task turns timed`() { + val form = TaskForm(title = "t", listId = 1, due = now + 2.days + 3.hours) + val ownTime = TaskReminder.allDay(0, 17 * 60) + assertThat(switched(listOf(ownTime, TaskReminder(2_880)), form)).containsExactly(TaskReminder(2_880)) + assertThat(switched(listOf(ownTime), form)).containsExactly(dayBeforeDue, hourBeforeDue) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ical/AlarmCodecTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ical/AlarmCodecTest.kt new file mode 100644 index 0000000..7867906 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ical/AlarmCodecTest.kt @@ -0,0 +1,311 @@ +package de.jeanlucmakiola.agendula.data.tasks.ical + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference +import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity +import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity +import de.jeanlucmakiola.agendula.domain.allDayInstantOf +import de.jeanlucmakiola.agendula.domain.ical.ICalComponent +import de.jeanlucmakiola.agendula.domain.ical.ICalParser +import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import java.time.LocalDate +import kotlin.time.Duration.Companion.hours +import kotlin.time.Instant + +class AlarmCodecTest { + + private val due = Instant.parse("2026-10-01T15:00:00Z") + private val start = Instant.parse("2026-10-01T09:00:00Z") + private val timed = TaskEntity(listId = 1, uid = "t", due = due) + private val withStart = timed.copy(dtstart = start) + private val allDay = TaskEntity( + listId = 1, + uid = "t", + due = allDayInstantOf(LocalDate.of(2026, 10, 1)), + isAllDay = true, + ) + + private fun alarm(vararg lines: String): ICalComponent = + ICalParser.parse((listOf("BEGIN:VALARM") + lines + "END:VALARM").joinToString("\r\n")) + + private fun lead(minutes: Int, reference: AlarmReference = AlarmReference.DUE) = AlarmCodec.Lead(minutes, reference) + + @Nested + inner class Claiming { + + @Test + fun `a display alarm before due is claimed`() { + assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER;RELATED=END:-PT15M"), timed)) + .isEqualTo(lead(15)) + } + + @Test + fun `an audio alarm is claimed too, an email one is not`() { + assertThat(AlarmCodec.resolve(alarm("ACTION:AUDIO", "TRIGGER;RELATED=END:-PT5M"), timed)).isEqualTo(lead(5)) + assertThat(AlarmCodec.resolve(alarm("ACTION:EMAIL", "TRIGGER;RELATED=END:-PT5M"), timed)).isNull() + assertThat(AlarmCodec.resolve(alarm("TRIGGER;RELATED=END:-PT5M"), timed)).isNull() + } + + @Test + fun `alarms that are somebody else's to fire stay in the residue`() { + val unclaimable = listOf( + alarm("ACTION:DISPLAY", "X-APPLE-DEFAULT-ALARM:TRUE", "TRIGGER;RELATED=END:-PT15M"), + alarm("ACTION:DISPLAY", "X-APPLE-PROXIMITY:ARRIVE", "TRIGGER;VALUE=DATE-TIME:19760401T005545Z"), + alarm("ACTION:DISPLAY", "PROXIMITY:DEPART", "TRIGGER;RELATED=END:-PT15M"), + alarm("ACTION:DISPLAY", "REPEAT:2", "DURATION:PT5M", "TRIGGER;RELATED=END:-PT15M"), + alarm("ACTION:DISPLAY", "RELATED-TO;RELTYPE=SNOOZE:other", "TRIGGER;RELATED=END:-PT15M"), + alarm("ACTION:DISPLAY", "TRIGGER;VALUE=DATE-TIME:19760401T005545Z"), + ) + unclaimable.forEach { assertThat(AlarmCodec.resolve(it, timed)).isNull() } + } + + @Test + fun `a trigger after its anchor is not claimed, because the planner would fire it early`() { + assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER;RELATED=END:PT10M"), timed)).isNull() + } + + @Test + fun `a lead in seconds is not one the planner can reproduce`() { + assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER;RELATED=END:-PT90S"), timed)).isNull() + } + + @Test + fun `RELATED=END without a DUE has nothing to count from`() { + val noDue = timed.copy(due = null, dtstart = start) + assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER;RELATED=END:-PT15M"), noDue)).isNull() + } + + @Test + fun `an absolute UTC trigger becomes a lead before due`() { + assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER;VALUE=DATE-TIME:20261001T143000Z"), timed)) + .isEqualTo(lead(30)) + } + + @Test + fun `an absolute trigger on a recurring task has no single lead`() { + val recurring = timed.copy(rrule = "FREQ=DAILY") + assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER;VALUE=DATE-TIME:20261001T143000Z"), recurring)) + .isNull() + } + + @Test + fun `the identity is taken from X-WR-ALARMUID, then UID`() { + val apple = AlarmCodec.fromVAlarm(alarm("ACTION:DISPLAY", "X-WR-ALARMUID:apple", "UID:rfc", "TRIGGER:-PT5M"), timed) + val rfc = AlarmCodec.fromVAlarm(alarm("ACTION:DISPLAY", "UID:rfc", "TRIGGER:-PT5M"), timed) + assertThat(apple?.uid).isEqualTo("apple") + assertThat(rfc?.uid).isEqualTo("rfc") + } + } + + @Nested + inner class Anchors { + + @Test + fun `RELATED=START with a distinct DTSTART is a before-start reminder`() { + assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER;RELATED=START:-PT10M"), withStart)) + .isEqualTo(lead(10, AlarmReference.START)) + } + + @Test + fun `RELATED is START by default`() { + assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER:-PT10M"), withStart)) + .isEqualTo(lead(10, AlarmReference.START)) + } + + @Test + fun `START on a task whose start is its due is a before-due reminder`() { + val same = timed.copy(dtstart = due) + assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER:-PT10M"), same)).isEqualTo(lead(10)) + } + + @Test + fun `START on a task with only a DUE is Apple's shape, and reads as before-due`() { + assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER;RELATED=START:-PT30M"), timed)) + .isEqualTo(lead(30)) + } + } + + @Nested + inner class AllDay { + + @Test + fun `a relative trigger counts back from the date's midnight`() { + assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER;RELATED=END:-P1D"), allDay)) + .isEqualTo(lead(24 * 60)) + // 09:00 the day before. + assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER;RELATED=END:-PT15H"), allDay)) + .isEqualTo(lead(15 * 60)) + } + + @Test + fun `a time on the day itself is a time of day, not an after-due alarm`() { + assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER;RELATED=END:PT9H"), allDay)) + .isEqualTo(lead(-9 * 60)) + } + + @Test + fun `a trigger after the day is over is not claimed`() { + assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER;RELATED=END:PT24H"), allDay)).isNull() + } + + @Test + fun `an absolute trigger counts back from the due date`() { + // 09:00 Berlin the day before, as Apple writes it. + assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER;VALUE=DATE-TIME:20260930T070000Z"), allDay)) + .isEqualTo(lead(24 * 60)) + } + + @Test + fun `an authored all-day alarm is written in days`() { + val row = TaskAlarmEntity(taskId = 1, minutesBefore = 2 * 24 * 60, uid = "a") + assertThat(AlarmCodec.toVAlarm(row, allDay, due)?.property("TRIGGER")?.value).isEqualTo("-P2D") + val onTheDay = row.copy(minutesBefore = 0) + assertThat(AlarmCodec.toVAlarm(onTheDay, allDay, due)?.property("TRIGGER")?.value).isEqualTo("PT0S") + } + + @Test + fun `an authored all-day alarm with a time of its own is written from midnight`() { + val nine = TaskAlarmEntity(taskId = 1, minutesBefore = -9 * 60, uid = "a") + assertThat(AlarmCodec.toVAlarm(nine, allDay, due)?.property("TRIGGER")?.value).isEqualTo("PT9H") + val fivePmBefore = nine.copy(minutesBefore = 7 * 60) + assertThat(AlarmCodec.toVAlarm(fivePmBefore, allDay, due)?.property("TRIGGER")?.value).isEqualTo("-PT7H") + } + } + + @Nested + inner class Writing { + + private val earlier = due - 48.hours + + @Test + fun `an authored alarm names itself both ways and counts back from due`() { + val written = AlarmCodec.toVAlarm(TaskAlarmEntity(taskId = 1, minutesBefore = 15, uid = "a-1"), timed, earlier)!! + assertThat(ICalSerializer.serialize(written)).isEqualTo( + "BEGIN:VALARM\r\n" + + "UID:a-1\r\n" + + "X-WR-ALARMUID:a-1\r\n" + + "ACTION:DISPLAY\r\n" + + "DESCRIPTION:Reminder\r\n" + + "TRIGGER;RELATED=END:-PT15M\r\n" + + "END:VALARM\r\n", + ) + } + + @Test + fun `a before-start row is written against START`() { + val row = TaskAlarmEntity(taskId = 1, minutesBefore = 10, reference = AlarmReference.START, uid = "a") + val trigger = AlarmCodec.toVAlarm(row, withStart, earlier)?.property("TRIGGER") + assertThat(trigger?.param("RELATED")).isEqualTo("START") + assertThat(trigger?.value).isEqualTo("-PT10M") + } + + @Test + fun `a row whose anchor is gone is not written, so the resource stays valid`() { + val row = TaskAlarmEntity(taskId = 1, minutesBefore = 15, uid = "a") + assertThat(AlarmCodec.toVAlarm(row, timed.copy(due = null), earlier)).isNull() + } + + @Test + fun `an alarm long past is written acknowledged`() { + val row = TaskAlarmEntity(taskId = 1, minutesBefore = 15, uid = "a") + val muchLater = due + 7.hours + assertThat(AlarmCodec.toVAlarm(row, timed, muchLater)?.property("ACKNOWLEDGED")?.value) + .isEqualTo("20261001T220000Z") + assertThat(AlarmCodec.toVAlarm(row, timed, earlier)?.property("ACKNOWLEDGED")).isNull() + } + + @Test + fun `one still inside the missed-reminder grace is not, or it would silence the late reminder here`() { + val row = TaskAlarmEntity(taskId = 1, minutesBefore = 15, uid = "a") + assertThat(AlarmCodec.toVAlarm(row, timed, due + 1.hours)?.property("ACKNOWLEDGED")).isNull() + } + + @Test + fun `a before-start row on a task that lost its start goes out before due`() { + val row = TaskAlarmEntity(taskId = 1, minutesBefore = 10, reference = AlarmReference.START, uid = "a") + val trigger = AlarmCodec.toVAlarm(row, timed, earlier)?.property("TRIGGER") + assertThat(trigger?.param("RELATED")).isEqualTo("END") + assertThat(trigger?.value).isEqualTo("-PT10M") + } + + @Test + fun `a before-due row on a task without a due goes out before start`() { + val row = TaskAlarmEntity(taskId = 1, minutesBefore = 10, uid = "a") + val trigger = AlarmCodec.toVAlarm(row, withStart.copy(due = null), earlier)?.property("TRIGGER") + assertThat(trigger?.param("RELATED")).isEqualTo("START") + assertThat(trigger?.value).isEqualTo("-PT10M") + } + + @Test + fun `an all-day row kept in whole days by an earlier build keeps the original's time`() { + val row = TaskAlarmEntity( + taskId = 1, + minutesBefore = 0, + uid = "a", + raw = "BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER;RELATED=END:PT9H\r\nEND:VALARM\r\n", + ) + assertThat(AlarmCodec.toVAlarm(row, allDay, earlier)?.property("TRIGGER")?.value).isEqualTo("PT9H") + } + + @Test + fun `the wire lead is what the row reads back as`() { + val afterDue = TaskAlarmEntity(taskId = 1, minutesBefore = -15, uid = "a") + assertThat(AlarmCodec.wireLead(afterDue, timed)).isEqualTo(lead(0)) + assertThat(AlarmCodec.wireLead(afterDue, allDay)).isEqualTo(lead(-15)) + } + + @Test + fun `a row rounded by an earlier build does not claim the original's time as its own`() { + val row = TaskAlarmEntity( + taskId = 1, + minutesBefore = 0, + uid = "a", + raw = "BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER;RELATED=END:PT9H\r\nEND:VALARM\r\n", + ) + assertThat(AlarmCodec.wireLead(row, allDay)).isEqualTo(lead(0)) + } + } + + @Nested + inner class Unchanged { + + private fun claimed(text: String, task: TaskEntity) = + checkNotNull(AlarmCodec.fromVAlarm(alarm(*text.split("|").toTypedArray()), task)) + + @Test + fun `an unchanged row is written back verbatim, whatever spelling it arrived in`() { + val row = claimed("ACTION:DISPLAY|TRIGGER;RELATED=END:-P0DT0H15M0S|X-VENDOR:keep", timed) + assertThat(row.minutesBefore).isEqualTo(15) + val written = AlarmCodec.toVAlarm(row, timed, due)!! + assertThat(written.property("TRIGGER")?.value).isEqualTo("-P0DT0H15M0S") + assertThat(written.property("X-VENDOR")?.value).isEqualTo("keep") + } + + @Test + fun `a relative original survives a due-date edit`() { + val row = claimed("ACTION:DISPLAY|TRIGGER;RELATED=END:-PT15M|X-VENDOR:keep", timed) + val moved = timed.copy(due = due + 24.hours) + assertThat(AlarmCodec.toVAlarm(row, moved, due)?.property("X-VENDOR")).isNotNull() + } + + @Test + fun `an absolute original stops matching once due moves, and the row is authored fresh`() { + val row = claimed("ACTION:DISPLAY|TRIGGER;VALUE=DATE-TIME:20261001T143000Z|X-WR-ALARMUID:abs", timed) + val moved = timed.copy(due = due + 24.hours) + val written = AlarmCodec.toVAlarm(row, moved, due)!! + assertThat(written.property("TRIGGER")?.value).isEqualTo("-PT30M") + // Under its old identity, so other clients see it change, not appear. + assertThat(written.property("X-WR-ALARMUID")?.value).isEqualTo("abs") + } + + @Test + fun `an edited lead is authored fresh`() { + val row = claimed("ACTION:DISPLAY|TRIGGER;RELATED=END:-PT15M|X-VENDOR:keep", timed).copy(minutesBefore = 60) + val written = AlarmCodec.toVAlarm(row, timed, due)!! + assertThat(written.property("TRIGGER")?.value).isEqualTo("-PT1H") + assertThat(written.property("X-VENDOR")).isNull() + } + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ical/VTodoMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ical/VTodoMapperTest.kt index b2e1c5e..03461b4 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ical/VTodoMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ical/VTodoMapperTest.kt @@ -1,6 +1,8 @@ package de.jeanlucmakiola.agendula.data.tasks.ical import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference +import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE import de.jeanlucmakiola.agendula.domain.TaskStatus @@ -8,6 +10,7 @@ import de.jeanlucmakiola.agendula.domain.ical.ICalComponent import de.jeanlucmakiola.agendula.domain.ical.ICalParser import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test +import kotlin.time.Duration.Companion.days import kotlin.time.Instant class VTodoMapperTest { @@ -442,4 +445,64 @@ class VTodoMapperTest { timezone = timezone, unknownProperties = unknownProperties, ) + + @Nested + inner class Alarms { + + private val due = Instant.parse("2026-10-01T15:00:00Z") + private val task = TaskEntity(listId = 1, uid = "t-1", title = "Pay rent", due = due) + + @Test + fun `claimed alarms leave the residue and unclaimed ones stay`() { + val mapped = VTodoMapper.read( + vtodo( + "DUE:20261001T150000Z", + "BEGIN:VALARM", "ACTION:DISPLAY", "TRIGGER;RELATED=END:-PT15M", "END:VALARM", + "BEGIN:VALARM", "ACTION:EMAIL", "TRIGGER;RELATED=END:-PT1H", "END:VALARM", + ), + ) + assertThat(mapped.alarms.map { it.minutesBefore }).containsExactly(15) + assertThat(mapped.entity.unknownProperties).contains("ACTION:EMAIL") + assertThat(mapped.entity.unknownProperties).doesNotContain("ACTION:DISPLAY") + } + + @Test + fun `the task's reminders are written ahead of the residue's alarms`() { + val withResidue = task.copy( + unknownProperties = "BEGIN:VALARM\r\nACTION:EMAIL\r\nTRIGGER;RELATED=END:-PT1H\r\nEND:VALARM\r\n", + ) + val written = VTodoMapper.write( + withResidue, + now = due - 1.days, + alarms = listOf(TaskAlarmEntity(taskId = 1, minutesBefore = 15, uid = "a-1")), + ) + val alarms = written.components("VALARM") + assertThat(alarms.map { it.property("ACTION")?.value }).containsExactly("DISPLAY", "EMAIL").inOrder() + assertThat(alarms.first().property("TRIGGER")?.value).isEqualTo("-PT15M") + } + + @Test + fun `a reminder left behind by a cleared due date does not make the task unuploadable`() { + val cleared = task.copy(due = null) + val written = VTodoMapper.write( + cleared, + alarms = listOf(TaskAlarmEntity(taskId = 1, minutesBefore = 15, uid = "a-1")), + ) + assertThat(written.components("VALARM")).isEmpty() + assertThat(VTodoMapper.validate(written)).isEmpty() + } + + @Test + fun `a reminder survives write and read with its identity`() { + val written = VTodoMapper.write( + task, + now = due - 1.days, + alarms = listOf(TaskAlarmEntity(taskId = 1, minutesBefore = 30, uid = "a-1")), + ) + val back = VTodoMapper.read(written).alarms.single() + assertThat(back.minutesBefore).isEqualTo(30) + assertThat(back.reference).isEqualTo(AlarmReference.DUE) + assertThat(back.uid).isEqualTo("a-1") + } + } } diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ical/VTodoRoundTripTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ical/VTodoRoundTripTest.kt index 55e7c6b..eb17eda 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ical/VTodoRoundTripTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ical/VTodoRoundTripTest.kt @@ -1,6 +1,7 @@ package de.jeanlucmakiola.agendula.data.tasks.ical import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference import de.jeanlucmakiola.agendula.domain.ical.ICalComponent import de.jeanlucmakiola.agendula.domain.ical.ICalParser import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer @@ -23,6 +24,7 @@ class VTodoRoundTripTest { "nextcloud-shared-confidential", "recurring-master-overrides", "valarm-unknown-nested", + "valarm-claimed-apple", "unknown-component", "unknown-tzid", "uid-special-chars", @@ -70,6 +72,29 @@ class VTodoRoundTripTest { assertThat(alarm.property("ACKNOWLEDGED")?.value).isEqualTo("20260901T113000Z") } + @Test + fun `a claimed alarm becomes a reminder and comes back exactly as it arrived`() { + val original = vtodosOf("valarm-claimed-apple").single() + val mapped = VTodoMapper.read(original) + + val alarm = mapped.alarms.single() + // RELATED=START with only a DUE: Apple's shape, read as before-due. + assertThat(alarm.minutesBefore).isEqualTo(30) + assertThat(alarm.reference).isEqualTo(AlarmReference.DUE) + assertThat(alarm.uid).isEqualTo("8E6C4A1E-0000-4C1B-9B1A-3F5F4C2D9A11") + assertThat(mapped.entity.unknownProperties.orEmpty()).doesNotContain("VALARM") + + val written = roundTrip(original).components("VALARM").single() + assertThat(written).isEqualTo(original.components("VALARM").single()) + } + + @Test + fun `an Apple default alarm is left in the residue`() { + val mapped = VTodoMapper.read(vtodosOf("valarm-unknown-nested").single()) + assertThat(mapped.alarms).isEmpty() + assertThat(mapped.entity.unknownProperties).contains("X-APPLE-DEFAULT-ALARM:TRUE") + } + @Test fun `an entirely unknown sub-component survives`() { val roundTripped = roundTrip(vtodosOf("unknown-component").single()) @@ -145,6 +170,7 @@ class VTodoRoundTripTest { entity = mapped.entity, parentUid = mapped.parentUid, now = Instant.fromEpochMilliseconds(0), + alarms = mapped.alarms, ) // Serialise and re-parse rather than comparing the tree directly: folding // and parameter quoting are where a serialiser loses data, and skipping diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriterTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriterTest.kt index b64fb59..8c8d523 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriterTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriterTest.kt @@ -3,6 +3,7 @@ package de.jeanlucmakiola.agendula.data.tasks.room import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.agendula.domain.Priority import de.jeanlucmakiola.agendula.domain.TaskForm +import de.jeanlucmakiola.agendula.domain.TaskReminder import de.jeanlucmakiola.agendula.domain.TaskStatus import org.junit.jupiter.api.Test import kotlin.time.Instant @@ -53,6 +54,17 @@ class TaskFormWriterTest { assertThat(entity.isDirty).isTrue() } + @Test + fun `an empty set the editor chose turns reminders off, any other keeps the flag`() { + val due = Instant.parse("2026-02-01T10:00:00Z") + val chosen = form(due = due).copy(remindersChosen = true) + assertThat(TaskFormWriter.apply(task(), chosen, NOW, ZONE).remindersOff).isTrue() + val withOne = chosen.copy(reminders = listOf(TaskReminder(15))) + assertThat(TaskFormWriter.apply(task().copy(remindersOff = true), withOne, NOW, ZONE).remindersOff).isFalse() + assertThat(TaskFormWriter.apply(task().copy(remindersOff = true), form(due = due), NOW, ZONE).remindersOff).isTrue() + assertThat(TaskFormWriter.apply(task(), form(due = due), NOW, ZONE).remindersOff).isFalse() + } + @Test fun `tags are trimmed and blanks dropped`() { val entity = TaskFormWriter.apply(task(), form(tags = listOf(" Work ", "", " ", "Home")), NOW, ZONE) diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/transfer/IcsImportTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/transfer/IcsImportTest.kt index 70819b4..cc0d78e 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/transfer/IcsImportTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/transfer/IcsImportTest.kt @@ -1,6 +1,8 @@ package de.jeanlucmakiola.agendula.data.tasks.transfer import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference +import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity import org.junit.jupiter.api.Test import java.io.ByteArrayOutputStream @@ -24,12 +26,20 @@ class IcsImportTest { override fun hasUid(listId: Long, uid: String) = rows.any { it.listId == listId && it.uid == uid } + override fun hasUidAnywhere(uid: String) = rows.any { it.uid == uid } + override fun insert(row: TaskEntity): Long { val id = nextId++ rows += row.copy(id = id) return id } + val alarms = mutableListOf() + + override fun insertAlarms(taskId: Long, alarms: List) { + this.alarms += alarms.map { it.copy(taskId = taskId) } + } + override fun masterByUid(listId: Long, uid: String) = rows.firstOrNull { it.listId == listId && it.uid == uid && it.recurrenceId == null } @@ -87,12 +97,55 @@ class IcsImportTest { val child = rows.single { it.recurrenceId != null } assertThat(master.rrule).isEqualTo("FREQ=WEEKLY;BYDAY=MO,TH") assertThat(master.unknownProperties).contains("X-APPLE-SORT-ORDER:7") - assertThat(master.unknownProperties).contains("BEGIN:VALARM") + // The alarm is a reminder now, carrying the original for the upload. + assertThat(master.unknownProperties).doesNotContain("BEGIN:VALARM") + val alarm = store.alarms.single() + assertThat(alarm.taskId).isEqualTo(master.id) + assertThat(alarm.minutesBefore).isEqualTo(15) + assertThat(alarm.reference).isEqualTo(AlarmReference.START) + assertThat(alarm.raw).contains("DESCRIPTION:Water") assertThat(child.masterId).isEqualTo(master.id) assertThat(child.title).isEqualTo("Water plants (balcony)") assertThat(rows.all { it.listId == 1L && it.isDirty && it.href == null }).isTrue() } + @Test + fun `an override keeps its own alarms and an Apple default alarm stays in the residue`() { + val withAlarm = vtodo( + "series", "Water plants (balcony)", + "RECURRENCE-ID:20260604T080000Z", + "DTSTART:20260604T100000Z", + "DUE:20260604T110000Z", + "BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER;RELATED=END:-PT1H\r\nEND:VALARM", + "BEGIN:VALARM\r\nACTION:DISPLAY\r\nX-APPLE-DEFAULT-ALARM:TRUE\r\nTRIGGER;RELATED=END:-PT5M\r\nEND:VALARM", + ) + val parsed = IcsImport.parse(listOf(IcsSource("plants.ics", calendar(series, withAlarm)))) + val store = FakeImportStore() + + IcsImport.write(parsed, ImportTarget.Existing(1L), store) + + val child = store.byUid("series").single { it.recurrenceId != null } + val own = store.alarms.filter { it.taskId == child.id } + assertThat(own.map { it.minutesBefore to it.reference }).containsExactly(60 to AlarmReference.DUE) + assertThat(child.unknownProperties).contains("X-APPLE-DEFAULT-ALARM:TRUE") + } + + @Test + fun `the same file in a second list gives the copy alarm identities of its own`() { + val parsed = IcsImport.parse(listOf(IcsSource("plants.ics", calendar(series)))) + val store = FakeImportStore() + + IcsImport.write(parsed, ImportTarget.Existing(1L), store) + IcsImport.write(parsed, ImportTarget.Existing(2L), store) + + val (first, second) = store.alarms + assertThat(first.raw).isNotNull() + assertThat(second.uid).isNotEqualTo(first.uid) + // Fresh, so an untouched copy is not re-sent as the original text either. + assertThat(second.raw).isNull() + assertThat(second.minutesBefore).isEqualTo(first.minutesBefore) + } + @Test fun `a parent later in the file is still linked`() { val child = vtodo("child", "Buy soil", "RELATED-TO;RELTYPE=PARENT:parent") diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskFormTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskFormTest.kt index fb42d1f..736e85c 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskFormTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskFormTest.kt @@ -31,9 +31,15 @@ class TaskFormTest { } @Test - fun `a reminder requires a due date`() { - val errors = TaskForm(title = "x", listId = 1, reminders = listOf(10)).validate() - assertThat(errors).contains(TaskFormError.REMINDER_WITHOUT_DUE) + fun `a reminder requires a start or due date`() { + val errors = TaskForm(title = "x", listId = 1, reminders = listOf(TaskReminder(10))).validate() + assertThat(errors).contains(TaskFormError.REMINDER_WITHOUT_DATE) + } + + @Test + fun `a start alone is enough for a reminder`() { + val errors = TaskForm(title = "x", listId = 1, start = Instant.fromEpochSeconds(0), reminders = listOf(TaskReminder(10))).validate() + assertThat(errors).doesNotContain(TaskFormError.REMINDER_WITHOUT_DATE) } @Test diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskReminderTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskReminderTest.kt new file mode 100644 index 0000000..64e3ba8 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskReminderTest.kt @@ -0,0 +1,60 @@ +package de.jeanlucmakiola.agendula.domain + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class TaskReminderTest { + + @Test + fun `whole days keep the device's all-day time`() { + assertThat(TaskReminder(0).allDay).isEqualTo(AllDayReminder(0, null)) + assertThat(TaskReminder(2 * 1_440).allDay).isEqualTo(AllDayReminder(2, null)) + } + + @Test + fun `anything else names a time of day`() { + assertThat(TaskReminder(-9 * 60).allDay).isEqualTo(AllDayReminder(0, 9 * 60)) + assertThat(TaskReminder(7 * 60).allDay).isEqualTo(AllDayReminder(1, 17 * 60)) + assertThat(TaskReminder(15).allDay).isEqualTo(AllDayReminder(1, 23 * 60 + 45)) + } + + @Test + fun `a day and a time round-trip`() { + listOf(0 to 9 * 60, 1 to 17 * 60, 7 to 30, 3 to null).forEach { (days, minute) -> + assertThat(TaskReminder.allDay(days, minute).allDay).isEqualTo(AllDayReminder(days, minute)) + } + } + + @Test + fun `a lead past the day is clamped to its last minute`() { + assertThat(TaskReminder(-1_440).allDay).isEqualTo(AllDayReminder(0, 1_439)) + } + + @Test + fun `midnight is a minute past it`() { + assertThat(TaskReminder.allDay(1, 0).allDay).isEqualTo(AllDayReminder(1, 1)) + assertThat(TaskReminder.allDay(1, null).allDay).isEqualTo(AllDayReminder(1, null)) + } + + @Test + fun `a default lead keeps its anchor whatever its lead`() { + val reminders = listOf(TaskReminder(-540, fromStart = true), TaskReminder(1_440, fromStart = true), TaskReminder(-540)) + assertThat(reminders.map { it.asReminderLead().asTaskReminder() }).isEqualTo(reminders) + } + + @Test + fun `whole days round to the nearest`() { + assertThat(TaskReminder(60).inWholeDays()).isEqualTo(TaskReminder(0)) + assertThat(TaskReminder(23 * 60, fromStart = true).inWholeDays()).isEqualTo(TaskReminder(1_440, fromStart = true)) + assertThat(TaskReminder(-540).inWholeDays()).isEqualTo(TaskReminder(0)) + } + + @Test + fun `all-day reminders are listed in the order they ring`() { + val eightPm = TaskReminder(0) + val nine = TaskReminder.allDay(0, 9 * 60) + val dayBefore = TaskReminder(1_440) + assertThat(listOf(eightPm, nine, dayBefore).sortedWith(taskReminderDisplayOrder(true, 20 * 60))) + .containsExactly(dayBefore, nine, eightPm).inOrder() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/TestTasks.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/TestTasks.kt index ad92ce0..54119ee 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/domain/TestTasks.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/TestTasks.kt @@ -14,6 +14,8 @@ fun testTask( completedAt: Instant? = null, parentId: Long? = null, tags: List = emptyList(), + start: Instant? = null, + created: Instant? = null, ): Task = Task( taskId = id, listId = listId, @@ -24,7 +26,7 @@ fun testTask( priority = priority, status = status, percentComplete = null, - start = null, + start = start, due = due, isAllDay = isAllDay, timeZone = null, @@ -37,7 +39,7 @@ fun testTask( isRecurring = false, occurrenceStart = null, distanceFromCurrent = 0, - created = null, + created = created, lastModified = null, tags = tags, ) diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/ical/ICalDurationTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/ical/ICalDurationTest.kt new file mode 100644 index 0000000..ce50d3e --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/ical/ICalDurationTest.kt @@ -0,0 +1,36 @@ +package de.jeanlucmakiola.agendula.domain.ical + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** RFC 5545 §3.3.6 `dur-value`, as alarm triggers use it. */ +class ICalDurationTest { + + @Test + fun `the forms other clients write`() { + assertThat(ICalValues.parseDuration("-PT15M")).isEqualTo(-900L) + assertThat(ICalValues.parseDuration("+PT5M")).isEqualTo(300L) + assertThat(ICalValues.parseDuration("PT0S")).isEqualTo(0L) + assertThat(ICalValues.parseDuration("P1W")).isEqualTo(7 * 86_400L) + assertThat(ICalValues.parseDuration("-P1DT2H")).isEqualTo(-(86_400L + 7_200L)) + assertThat(ICalValues.parseDuration("-P0DT0H15M0S")).isEqualTo(-900L) + assertThat(ICalValues.parseDuration("-PT90S")).isEqualTo(-90L) + } + + @Test + fun `what is not a duration is null`() { + listOf("", "P", "PT", "-P1DT", "15M", "PT15", "P1H", "20261001T090000Z", "P99999999999999999999D") + .forEach { assertThat(ICalValues.parseDuration(it)).isNull() } + } + + @Test + fun `written in days and time, and read back the same`() { + assertThat(ICalValues.formatDuration(-900)).isEqualTo("-PT15M") + assertThat(ICalValues.formatDuration(0)).isEqualTo("PT0S") + assertThat(ICalValues.formatDuration(-86_400)).isEqualTo("-P1D") + assertThat(ICalValues.formatDuration(-(86_400L + 5_400L))).isEqualTo("-P1DT1H30M") + listOf(-900L, 0L, -86_400L, -93_784L, 3_600L).forEach { + assertThat(ICalValues.parseDuration(ICalValues.formatDuration(it))).isEqualTo(it) + } + } +} diff --git a/app/src/test/resources/vtodo/README.md b/app/src/test/resources/vtodo/README.md index cf4ee53..dd603a6 100644 --- a/app/src/test/resources/vtodo/README.md +++ b/app/src/test/resources/vtodo/README.md @@ -13,7 +13,8 @@ VTIMEZONE bodies, fold positions, parameter quoting). |---|---| | `nextcloud-shared-confidential.ics` | A `CLASS:CONFIDENTIAL` task as Nextcloud rewrites it on GET from a **shared** calendar — `DUE`, `STATUS`, `COMPLETED`, `PERCENT-COMPLETE`, `PRIORITY` and `RELATED-TO` already stripped, ETag left untouched. Re-PUTting this destroys the owner's task | | `recurring-master-overrides.ics` | Master plus two `RECURRENCE-ID` overrides in one resource, sharing a UID | -| `valarm-unknown-nested.ics` | Unknown properties **inside** a `VALARM` — the case a flat property-per-row model cannot represent | +| `valarm-unknown-nested.ics` | Unknown properties **inside** a `VALARM` — the case a flat property-per-row model cannot represent. An Apple *default* alarm, so it stays in the residue | +| `valarm-claimed-apple.ics` | The same Apple alarm without `X-APPLE-DEFAULT-ALARM`, so it is **claimed** as a reminder: `RELATED=START` on a task with only `DUE` (Apple's non-conformant shape) reads as a before-due lead, and the original must come back verbatim through the row's `raw` | | `unknown-component.ics` | An entirely unknown sub-component | | `unknown-tzid.ics` | A `TZID` no device tzdb knows, with its own `VTIMEZONE` | | `uid-special-chars.ics` | A UID containing `/` and `@` — the href sanitiser must not assume it is filename-safe | diff --git a/app/src/test/resources/vtodo/valarm-claimed-apple.ics b/app/src/test/resources/vtodo/valarm-claimed-apple.ics new file mode 100644 index 0000000..9c658fd --- /dev/null +++ b/app/src/test/resources/vtodo/valarm-claimed-apple.ics @@ -0,0 +1,17 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//iOS 18.2//EN +BEGIN:VTODO +UID:alarm-claimed-001 +DTSTAMP:20260901T120000Z +SUMMARY:Renew passport +DUE;TZID=Europe/Berlin:20261001T170000 +BEGIN:VALARM +ACTION:DISPLAY +DESCRIPTION:Reminder +TRIGGER;RELATED=START:-PT30M +ACKNOWLEDGED:20260901T113000Z +X-WR-ALARMUID:8E6C4A1E-0000-4C1B-9B1A-3F5F4C2D9A11 +END:VALARM +END:VTODO +END:VCALENDAR diff --git a/app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/CollectionSyncerTest.kt b/app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/CollectionSyncerTest.kt index ee5a5b7..d49e2bc 100644 --- a/app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/CollectionSyncerTest.kt +++ b/app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/CollectionSyncerTest.kt @@ -1,6 +1,8 @@ package de.jeanlucmakiola.agendula.data.sync import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference +import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity import de.jeanlucmakiola.agendula.data.tasks.room.TaskMove @@ -970,6 +972,150 @@ class CollectionSyncerTest { assertThat(report.discardedEdits.single().cause).isEqualTo(DiscardedEdit.Cause.DELETE_LOST) } + // ------------------------------------------------------------- reminders + + private val due = Instant.parse("2026-03-05T09:00:00Z") + private val alarm15 = "BEGIN:VALARM\r\nACTION:DISPLAY\r\nX-WR-ALARMUID:srv-1\r\n" + + "TRIGGER;RELATED=END:-PT15M\r\nEND:VALARM" + + private fun alarm(taskId: Long, minutes: Int, uid: String = "a-$minutes") = + TaskAlarmEntity(taskId = taskId, minutesBefore = minutes, uid = uid) + + @Test fun `an upload carries the task's reminders as VALARMs`() { + store.rows += task(id = 1, uid = "a", title = "Pay rent").copy(due = due, isDirty = true) + store.alarms += alarm(1, 15, uid = "a-1") + + sync() + + val body = remote.resources.values.single().body + assertThat(body).contains("BEGIN:VALARM") + assertThat(body).contains("TRIGGER;RELATED=END:-PT15M") + assertThat(body).contains("X-WR-ALARMUID:a-1") + } + + @Test fun `the alarm keeps its identity across uploads`() { + store.rows += task(id = 1, uid = "a", title = "Pay rent").copy(due = due, isDirty = true) + store.alarms += alarm(1, 15, uid = "a-1") + sync() + val first = remote.resources.values.single().body + store.rows[0] = store.rows[0].copy(title = "Pay the rent", isDirty = true) + + sync() + + val second = remote.resources.values.single().body + assertThat(second).isNotEqualTo(first) + assertThat(second).contains("X-WR-ALARMUID:a-1") + } + + @Test fun `a downloaded alarm becomes a reminder and leaves the residue`() { + remote.put("one.ics", vtodo("a", "Pay rent", extra = "DUE:20260305T090000Z\r\n$alarm15")) + + sync() + + val row = store.rows.single() + val reminder = store.alarms.single() + assertThat(reminder.taskId).isEqualTo(row.id) + assertThat(reminder.minutesBefore).isEqualTo(15) + assertThat(reminder.uid).isEqualTo("srv-1") + assertThat(row.unknownProperties.orEmpty()).doesNotContain("VALARM") + assertThat(row.isDirty).isFalse() + } + + @Test fun `a re-download keeps the reminder's row, and a removal on the server clears it`() { + remote.put("one.ics", vtodo("a", "Pay rent", extra = "DUE:20260305T090000Z\r\n$alarm15")) + sync() + val id = store.alarms.single().id + remote.put("one.ics", vtodo("a", "Pay the rent", extra = "DUE:20260305T090000Z\r\n$alarm15"), eTag = "e-2") + + sync() + + // Matched by identity, so the row is updated in place rather than churned. + assertThat(store.alarms.single().id).isEqualTo(id) + + remote.put("one.ics", vtodo("a", "Pay the rent", extra = "DUE:20260305T090000Z"), eTag = "e-3") + sync() + + assertThat(store.alarms).isEmpty() + // Turned off elsewhere: "none", so the device default does not take over. + assertThat(store.rows.single().remindersOff).isTrue() + } + + @Test fun `a task that never had alarms still takes the default`() { + remote.put("one.ics", vtodo("a", "Pay rent", extra = "DUE:20260305T090000Z")) + + sync() + + assertThat(store.rows.single().remindersOff).isFalse() + } + + @Test fun `an all-day reminder at a time of its own round-trips from midnight`() { + // 09:00 on the day. + store.rows += task(id = 1, uid = "a", title = "Pay rent") + .copy(due = Instant.parse("2026-03-05T00:00:00Z"), isAllDay = true, isDirty = true) + store.alarms += alarm(1, -9 * 60, uid = "a-1") + sync() + val sent = remote.resources.values.single() + assertThat(sent.body).contains("TRIGGER;RELATED=END:PT9H") + + sent.eTag = de.jeanlucmakiola.caldav.ETag("e-2", weak = false) + sync() + + assertThat(store.alarms.single().minutesBefore).isEqualTo(-9 * 60) + } + + @Test fun `an alarm coming back as we sent it keeps the lead the wire could not carry`() { + // A lead past due is clamped to "at due" on the way out. + store.rows += task(id = 1, uid = "a", title = "Pay rent").copy(due = due, isDirty = true) + store.alarms += alarm(1, -15, uid = "a-1") + sync() + val sent = remote.resources.values.single() + assertThat(sent.body).contains("TRIGGER;RELATED=END:PT0S") + + sent.eTag = de.jeanlucmakiola.caldav.ETag("e-2", weak = false) + sync() + + assertThat(store.alarms.single().minutesBefore).isEqualTo(-15) + } + + @Test fun `a before-start reminder on a task without a start survives the round trip`() { + store.rows += task(id = 1, uid = "a", title = "Pay rent").copy(due = due, isDirty = true) + store.alarms += alarm(1, 10, uid = "s-1").copy(reference = AlarmReference.START) + sync() + val sent = remote.resources.values.single() + // Sent from DUE, which is what the planner fires it from. + assertThat(sent.body).contains("TRIGGER;RELATED=END:-PT10M") + + sent.eTag = de.jeanlucmakiola.caldav.ETag("e-2", weak = false) + sync() + + assertThat(store.alarms.single().reference).isEqualTo(AlarmReference.START) + } + + @Test fun `an override without alarms has none, whatever its master carries`() { + val body = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n" + + "BEGIN:VTODO\r\nUID:s\r\nSUMMARY:Water\r\nDUE:20260305T090000Z\r\nRRULE:FREQ=DAILY\r\n" + + "$alarm15\r\nEND:VTODO\r\n" + + "BEGIN:VTODO\r\nUID:s\r\nSUMMARY:Water (late)\r\nRECURRENCE-ID:20260306T090000Z\r\n" + + "DUE:20260306T120000Z\r\nEND:VTODO\r\nEND:VCALENDAR\r\n" + remote.put("s.ics", body) + + sync() + + val master = store.rows.single { it.recurrenceId == null } + assertThat(store.alarms.map { it.taskId }).containsExactly(master.id) + } + + @Test fun `a 412 discards a local reminder edit along with the rest`() { + remote.put("one.ics", vtodo("a", "Pay rent", extra = "DUE:20260305T090000Z\r\n$alarm15"), eTag = "newer") + store.rows += task(id = 1, uid = "a", title = "Pay rent") + .copy(href = "http://server/dav/tasks/one.ics", etag = "stale", due = due, isDirty = true) + store.alarms += alarm(1, 60) + + sync() + + assertThat(store.alarms.map { it.minutesBefore }).containsExactly(15) + } + private fun task( id: Long, uid: String, 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 c59b3af..0b18304 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 @@ -1,5 +1,6 @@ package de.jeanlucmakiola.agendula.data.sync +import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity import de.jeanlucmakiola.caldav.CalendarCollection import de.jeanlucmakiola.caldav.ChangeSet @@ -19,6 +20,10 @@ import okhttp3.HttpUrl.Companion.toHttpUrl class FakeStore(rows: List = emptyList()) : SyncStore { val rows = rows.toMutableList() + + /** `task_alarms`, cascading with [rows] as the foreign key does. */ + val alarms = mutableListOf() + private var nextAlarmId = 1L val readOnlyWrites = mutableListOf>() /** In order, so a test can prove the token was stored *after* its bodies. */ @@ -27,7 +32,7 @@ class FakeStore(rows: List = emptyList()) : SyncStore { override fun rowsIn(listId: Long) = this.rows.filter { it.listId == listId } - override fun insert(row: TaskEntity): Long { + fun insert(row: TaskEntity): Long { // Computed against the rows as they stand, not at construction: tests // seed `rows` directly after building this, and an id fixed up front // collides with the first seeded row — which then makes a delete of one @@ -45,6 +50,30 @@ class FakeStore(rows: List = emptyList()) : SyncStore { override fun deleteAll(taskIds: List) { rows.removeAll { it.id in taskIds } + alarms.removeAll { it.taskId in taskIds } + } + + override fun alarmsFor(taskIds: List) = alarms.filter { it.taskId in taskIds }.groupBy { it.taskId } + + override fun upsertWithAlarms(row: TaskEntity, alarms: List): Long { + if (row.id != 0L && rows.none { it.id == row.id }) return row.id + val id = if (row.id == 0L) insert(row) else row.id.also { update(row) } + val hadAny = this.alarms.any { it.taskId == id } + // Matched by uid, then lead, as `TaskAlarmDao.replaceFromServer` does. + val existing = this.alarms.filter { it.taskId == id }.toMutableList() + this.alarms.removeAll { it.taskId == id } + alarms.forEach { alarm -> + val match = existing.firstOrNull { it.uid == alarm.uid } + ?: existing.firstOrNull { it.minutesBefore == alarm.minutesBefore && it.reference == alarm.reference } + match?.let(existing::remove) + this.alarms += alarm.copy(id = match?.id ?: nextAlarmId++, taskId = id) + } + val index = rows.indexOfFirst { it.id == id } + when { + alarms.isNotEmpty() -> rows[index] = rows[index].copy(remindersOff = false) + hadAny -> rows[index] = rows[index].copy(remindersOff = true) + } + return id } override fun markSynced(taskIds: List, href: String?, eTag: String?) { diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 7ae05d8..e6a10be 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -1,7 +1,7 @@ --- title: Privacy Policy — Agendula description: What Agendula does with your data. No servers, no account, no analytics — your tasks stay on your device unless you add a CalDAV server yourself. -updated: 2026-10-15 +updated: 2026-10-19 --- -**Last updated:** 15 October 2026 +**Last updated:** 19 October 2026 Applies to the Android app **Agendula** (package `de.jeanlucmakiola.agendula`), all versions and all distribution channels. @@ -78,8 +78,9 @@ exported from the device. ### What is transmitted, and to whom -- The tasks in the synchronised lists, as standard iCalendar (`VTODO`) data, - and the credentials needed to authenticate. +- The tasks in the synchronised lists, as standard iCalendar (`VTODO`) data — + including their reminders, as `VALARM`s — and the credentials needed to + authenticate. - Requests carry the user agent `Agendula (Android)` — a fixed string, so that you can recognise and revoke the session on your server. No device identifier is sent. diff --git a/floret-kit b/floret-kit index 4858e00..68d6432 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 4858e00dfabb61f6090e569152187bd9ff933817 +Subproject commit 68d6432434553d017d72faf5a05baf02a99b54d6