Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e34c60577 | ||
|
|
caf4357cee | ||
|
|
97164feb39 | ||
|
|
8a521d32f9 | ||
|
|
b68582177b | ||
|
|
05f43d6f76 | ||
|
|
7498ffb37e | ||
|
|
7bcac70cec | ||
|
|
023c0e0157 | ||
|
|
efcd641ea8 | ||
|
|
7d072d0bf7 | ||
|
|
bda1a60959 | ||
|
|
2d7b6309c0 | ||
|
|
78b1cfbaed | ||
|
|
2887176e88 | ||
|
|
44da66bb85 | ||
|
|
c194984a8b | ||
|
|
8f06f24112 | ||
|
|
fb678d008b | ||
|
|
0770158b02 | ||
|
|
d64a770164 | ||
|
|
ff0cc28465 | ||
|
|
6a1733d97b | ||
|
|
7a94ee6786 | ||
|
|
7b5bf86cfa | ||
|
|
ddff57e8fe | ||
|
|
150e5c5f2c | ||
|
|
75ad41c41e |
No files matched your search
@@ -7,6 +7,58 @@ All notable changes to this project are documented here. The format follows
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.3.1] - 2026-11-09
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Ticking off the current occurrence of a repeating task moves the series on
|
||||||
|
to its next date and keeps a completed copy, as tasks.org and Apple
|
||||||
|
Reminders do. Other apps and the web views of servers such as Infomaniak now
|
||||||
|
see it as done instead of still open. Unticking it moves the series back,
|
||||||
|
as long as the series hasn't moved on or changed its start since (#137).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- With tasks.org as the storage provider, recurring tasks are recognised as
|
||||||
|
recurring. Before, editing one removed its recurrence and ticking it off
|
||||||
|
closed the whole series. With tasks.org and OpenTasks alike, the editor now
|
||||||
|
shows the task's repeat rule instead of none, and an occurrence changed on
|
||||||
|
its own stays part of its series (#135).
|
||||||
|
- With tasks.org or OpenTasks as the storage provider, the order you give your
|
||||||
|
lists is kept instead of snapping back (#136).
|
||||||
|
|
||||||
|
## [1.3.0] - 2026-11-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Self-hosted servers with a self-signed certificate can be added: Agendula
|
||||||
|
shows the certificate's fingerprint and asks whether to trust it. A server
|
||||||
|
reached over plain http can be added too, after a warning. If a trusted
|
||||||
|
certificate is later renewed, "Sign in again" on the account asks about the
|
||||||
|
new one (#68).
|
||||||
|
- An account can be given its own name (#105).
|
||||||
|
- Pull down on the task list to sync (#73).
|
||||||
|
- Task list sections can be collapsed (#91).
|
||||||
|
- A pure black theme for AMOLED screens (#99).
|
||||||
|
- Agendula can show up as "Tasks" in your launcher, picked from launcher
|
||||||
|
previews as in Calendula (#104).
|
||||||
|
- Reminder notifications show the task's description (#94).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Adding or opening a task from a widget returns you to the home screen
|
||||||
|
afterwards (#93).
|
||||||
|
- A swipe that starts at the screen edge no longer swipes the task (#90).
|
||||||
|
- Today also lists tasks that start today, and keeps the ones completed today
|
||||||
|
in a Completed section (#123).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- A task created from Today, or from a Today widget, is due today, so it shows
|
||||||
|
up there (#122).
|
||||||
|
- Completing a task from the list, a widget or another device now cancels its
|
||||||
|
upcoming reminder and clears one already showing (#127, #129).
|
||||||
|
- The splash screen follows the app's theme, including Black and dynamic
|
||||||
|
colour, instead of flashing white in dark mode (#119).
|
||||||
|
- A task saved without its default start or due reminder because it had
|
||||||
|
already rung (a start of "now", say) gets it once that date is moved later,
|
||||||
|
if the task has no reminder from that date (#121).
|
||||||
|
|
||||||
## [1.2.0] - 2026-10-26
|
## [1.2.0] - 2026-10-26
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ android {
|
|||||||
// (1.0.x: 1.0.0 -> 10000; from 1.1.0: 1.1.0-beta.1 -> 1010001,
|
// (1.0.x: 1.0.0 -> 10000; from 1.1.0: 1.1.0-beta.1 -> 1010001,
|
||||||
// 1.1.0 -> 1010099), and CI fails if the committed one doesn't match.
|
// 1.1.0 -> 1010099), and CI fails if the committed one doesn't match.
|
||||||
// See docs/RELEASING.md.
|
// See docs/RELEASING.md.
|
||||||
versionCode = 1020099
|
versionCode = 1030199
|
||||||
versionName = "1.2.0"
|
versionName = "1.3.1"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,553 @@
|
|||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 4,
|
||||||
|
"identityHash": "47f3bedbfbf4ddd1db32864c718de83a",
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"tableName": "accounts",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `display_name` TEXT NOT NULL, `principal_url` TEXT, `home_set_url` TEXT, `username` TEXT, `last_sync_at` INTEGER, `last_sync_error` TEXT, `label` TEXT)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "displayName",
|
||||||
|
"columnName": "display_name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "principalUrl",
|
||||||
|
"columnName": "principal_url",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "homeSetUrl",
|
||||||
|
"columnName": "home_set_url",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "username",
|
||||||
|
"columnName": "username",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lastSyncAt",
|
||||||
|
"columnName": "last_sync_at",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lastSyncError",
|
||||||
|
"columnName": "last_sync_error",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "label",
|
||||||
|
"columnName": "label",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "task_lists",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `account_id` INTEGER, `is_visible` INTEGER NOT NULL DEFAULT 1, `is_synced` INTEGER NOT NULL DEFAULT 1, `owner` TEXT, `is_read_only` INTEGER NOT NULL DEFAULT 0, `sort_order` INTEGER NOT NULL DEFAULT 0, `href` TEXT, `ctag` TEXT, `sync_token` TEXT, `is_dirty` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`account_id`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "color",
|
||||||
|
"columnName": "color",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "accountId",
|
||||||
|
"columnName": "account_id",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isVisible",
|
||||||
|
"columnName": "is_visible",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isSynced",
|
||||||
|
"columnName": "is_synced",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "owner",
|
||||||
|
"columnName": "owner",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isReadOnly",
|
||||||
|
"columnName": "is_read_only",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "sortOrder",
|
||||||
|
"columnName": "sort_order",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "href",
|
||||||
|
"columnName": "href",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "ctag",
|
||||||
|
"columnName": "ctag",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "syncToken",
|
||||||
|
"columnName": "sync_token",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isDirty",
|
||||||
|
"columnName": "is_dirty",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_task_lists_account_id",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"account_id"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_task_lists_account_id` ON `${TABLE_NAME}` (`account_id`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "accounts",
|
||||||
|
"onDelete": "SET NULL",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"account_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "tasks",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `list_id` INTEGER NOT NULL, `uid` TEXT NOT NULL, `href` TEXT, `etag` TEXT, `title` TEXT, `description` TEXT, `location` TEXT, `url` TEXT, `color` INTEGER, `categories` TEXT NOT NULL DEFAULT '', `status` INTEGER NOT NULL DEFAULT 0, `percent_complete` INTEGER, `completed_at` INTEGER, `priority` INTEGER NOT NULL DEFAULT 0, `classification` INTEGER, `dtstart` INTEGER, `due` INTEGER, `duration` TEXT, `is_all_day` INTEGER NOT NULL DEFAULT 0, `timezone` TEXT, `rrule` TEXT, `rdate` TEXT, `exdate` TEXT, `recurrence_id` INTEGER, `master_id` INTEGER, `parent_id` INTEGER, `sort_order` INTEGER NOT NULL DEFAULT 0, `created_at` INTEGER, `last_modified` INTEGER, `sequence` INTEGER NOT NULL DEFAULT 0, `is_dirty` INTEGER NOT NULL DEFAULT 0, `is_deleted` INTEGER NOT NULL DEFAULT 0, `unknown_properties` TEXT, `reminders_off` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`list_id`) REFERENCES `task_lists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`master_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parent_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "listId",
|
||||||
|
"columnName": "list_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "uid",
|
||||||
|
"columnName": "uid",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "href",
|
||||||
|
"columnName": "href",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "etag",
|
||||||
|
"columnName": "etag",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "title",
|
||||||
|
"columnName": "title",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "description",
|
||||||
|
"columnName": "description",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "location",
|
||||||
|
"columnName": "location",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "url",
|
||||||
|
"columnName": "url",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "color",
|
||||||
|
"columnName": "color",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "categories",
|
||||||
|
"columnName": "categories",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "''"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "status",
|
||||||
|
"columnName": "status",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "percentComplete",
|
||||||
|
"columnName": "percent_complete",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "completedAt",
|
||||||
|
"columnName": "completed_at",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "priority",
|
||||||
|
"columnName": "priority",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "classification",
|
||||||
|
"columnName": "classification",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "dtstart",
|
||||||
|
"columnName": "dtstart",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "due",
|
||||||
|
"columnName": "due",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "duration",
|
||||||
|
"columnName": "duration",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isAllDay",
|
||||||
|
"columnName": "is_all_day",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "timezone",
|
||||||
|
"columnName": "timezone",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "rrule",
|
||||||
|
"columnName": "rrule",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "rdate",
|
||||||
|
"columnName": "rdate",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "exdate",
|
||||||
|
"columnName": "exdate",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "recurrenceId",
|
||||||
|
"columnName": "recurrence_id",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "masterId",
|
||||||
|
"columnName": "master_id",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "parentId",
|
||||||
|
"columnName": "parent_id",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "sortOrder",
|
||||||
|
"columnName": "sort_order",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "created_at",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lastModified",
|
||||||
|
"columnName": "last_modified",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "sequence",
|
||||||
|
"columnName": "sequence",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isDirty",
|
||||||
|
"columnName": "is_dirty",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isDeleted",
|
||||||
|
"columnName": "is_deleted",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "unknownProperties",
|
||||||
|
"columnName": "unknown_properties",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "remindersOff",
|
||||||
|
"columnName": "reminders_off",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_tasks_list_id_is_deleted",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"list_id",
|
||||||
|
"is_deleted"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_list_id_is_deleted` ON `${TABLE_NAME}` (`list_id`, `is_deleted`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_tasks_parent_id",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"parent_id"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_parent_id` ON `${TABLE_NAME}` (`parent_id`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_tasks_master_id_recurrence_id",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"master_id",
|
||||||
|
"recurrence_id"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_master_id_recurrence_id` ON `${TABLE_NAME}` (`master_id`, `recurrence_id`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_tasks_is_dirty",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"is_dirty"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_is_dirty` ON `${TABLE_NAME}` (`is_dirty`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_tasks_list_id_uid_recurrence_id",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"list_id",
|
||||||
|
"uid",
|
||||||
|
"recurrence_id"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tasks_list_id_uid_recurrence_id` ON `${TABLE_NAME}` (`list_id`, `uid`, `recurrence_id`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "task_lists",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"list_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "tasks",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"master_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "tasks",
|
||||||
|
"onDelete": "SET NULL",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"parent_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "task_alarms",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `task_id` INTEGER NOT NULL, `minutes_before` INTEGER NOT NULL, `reference` TEXT NOT NULL DEFAULT 'DUE', `message` TEXT, `uid` TEXT NOT NULL DEFAULT '', `raw` TEXT, FOREIGN KEY(`task_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "taskId",
|
||||||
|
"columnName": "task_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "minutesBefore",
|
||||||
|
"columnName": "minutes_before",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "reference",
|
||||||
|
"columnName": "reference",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "'DUE'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "message",
|
||||||
|
"columnName": "message",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "uid",
|
||||||
|
"columnName": "uid",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "''"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "raw",
|
||||||
|
"columnName": "raw",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_task_alarms_task_id",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"task_id"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_task_alarms_task_id` ON `${TABLE_NAME}` (`task_id`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "tasks",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"task_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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, '47f3bedbfbf4ddd1db32864c718de83a')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,768 @@
|
|||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 5,
|
||||||
|
"identityHash": "fcb14ff68d892c72fa7459204276abb6",
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"tableName": "accounts",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `display_name` TEXT NOT NULL, `principal_url` TEXT, `home_set_url` TEXT, `username` TEXT, `last_sync_at` INTEGER, `last_sync_error` TEXT, `label` TEXT)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "displayName",
|
||||||
|
"columnName": "display_name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "principalUrl",
|
||||||
|
"columnName": "principal_url",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "homeSetUrl",
|
||||||
|
"columnName": "home_set_url",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "username",
|
||||||
|
"columnName": "username",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lastSyncAt",
|
||||||
|
"columnName": "last_sync_at",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lastSyncError",
|
||||||
|
"columnName": "last_sync_error",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "label",
|
||||||
|
"columnName": "label",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "task_lists",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `account_id` INTEGER, `is_visible` INTEGER NOT NULL DEFAULT 1, `is_synced` INTEGER NOT NULL DEFAULT 1, `owner` TEXT, `is_read_only` INTEGER NOT NULL DEFAULT 0, `sort_order` INTEGER NOT NULL DEFAULT 0, `href` TEXT, `ctag` TEXT, `sync_token` TEXT, `is_dirty` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`account_id`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "color",
|
||||||
|
"columnName": "color",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "accountId",
|
||||||
|
"columnName": "account_id",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isVisible",
|
||||||
|
"columnName": "is_visible",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isSynced",
|
||||||
|
"columnName": "is_synced",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "owner",
|
||||||
|
"columnName": "owner",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isReadOnly",
|
||||||
|
"columnName": "is_read_only",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "sortOrder",
|
||||||
|
"columnName": "sort_order",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "href",
|
||||||
|
"columnName": "href",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "ctag",
|
||||||
|
"columnName": "ctag",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "syncToken",
|
||||||
|
"columnName": "sync_token",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isDirty",
|
||||||
|
"columnName": "is_dirty",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_task_lists_account_id",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"account_id"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_task_lists_account_id` ON `${TABLE_NAME}` (`account_id`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "accounts",
|
||||||
|
"onDelete": "SET NULL",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"account_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "tasks",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `list_id` INTEGER NOT NULL, `uid` TEXT NOT NULL, `href` TEXT, `etag` TEXT, `title` TEXT, `description` TEXT, `location` TEXT, `url` TEXT, `color` INTEGER, `categories` TEXT NOT NULL DEFAULT '', `status` INTEGER NOT NULL DEFAULT 0, `percent_complete` INTEGER, `completed_at` INTEGER, `priority` INTEGER NOT NULL DEFAULT 0, `classification` INTEGER, `dtstart` INTEGER, `due` INTEGER, `duration` TEXT, `is_all_day` INTEGER NOT NULL DEFAULT 0, `timezone` TEXT, `rrule` TEXT, `rdate` TEXT, `exdate` TEXT, `recurrence_id` INTEGER, `master_id` INTEGER, `parent_id` INTEGER, `sort_order` INTEGER NOT NULL DEFAULT 0, `created_at` INTEGER, `last_modified` INTEGER, `sequence` INTEGER NOT NULL DEFAULT 0, `is_dirty` INTEGER NOT NULL DEFAULT 0, `is_deleted` INTEGER NOT NULL DEFAULT 0, `unknown_properties` TEXT, `reminders_off` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`list_id`) REFERENCES `task_lists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`master_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parent_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "listId",
|
||||||
|
"columnName": "list_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "uid",
|
||||||
|
"columnName": "uid",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "href",
|
||||||
|
"columnName": "href",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "etag",
|
||||||
|
"columnName": "etag",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "title",
|
||||||
|
"columnName": "title",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "description",
|
||||||
|
"columnName": "description",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "location",
|
||||||
|
"columnName": "location",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "url",
|
||||||
|
"columnName": "url",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "color",
|
||||||
|
"columnName": "color",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "categories",
|
||||||
|
"columnName": "categories",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "''"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "status",
|
||||||
|
"columnName": "status",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "percentComplete",
|
||||||
|
"columnName": "percent_complete",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "completedAt",
|
||||||
|
"columnName": "completed_at",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "priority",
|
||||||
|
"columnName": "priority",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "classification",
|
||||||
|
"columnName": "classification",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "dtstart",
|
||||||
|
"columnName": "dtstart",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "due",
|
||||||
|
"columnName": "due",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "duration",
|
||||||
|
"columnName": "duration",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isAllDay",
|
||||||
|
"columnName": "is_all_day",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "timezone",
|
||||||
|
"columnName": "timezone",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "rrule",
|
||||||
|
"columnName": "rrule",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "rdate",
|
||||||
|
"columnName": "rdate",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "exdate",
|
||||||
|
"columnName": "exdate",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "recurrenceId",
|
||||||
|
"columnName": "recurrence_id",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "masterId",
|
||||||
|
"columnName": "master_id",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "parentId",
|
||||||
|
"columnName": "parent_id",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "sortOrder",
|
||||||
|
"columnName": "sort_order",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "created_at",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lastModified",
|
||||||
|
"columnName": "last_modified",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "sequence",
|
||||||
|
"columnName": "sequence",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isDirty",
|
||||||
|
"columnName": "is_dirty",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isDeleted",
|
||||||
|
"columnName": "is_deleted",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "unknownProperties",
|
||||||
|
"columnName": "unknown_properties",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "remindersOff",
|
||||||
|
"columnName": "reminders_off",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_tasks_list_id_is_deleted",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"list_id",
|
||||||
|
"is_deleted"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_list_id_is_deleted` ON `${TABLE_NAME}` (`list_id`, `is_deleted`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_tasks_parent_id",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"parent_id"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_parent_id` ON `${TABLE_NAME}` (`parent_id`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_tasks_master_id_recurrence_id",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"master_id",
|
||||||
|
"recurrence_id"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_master_id_recurrence_id` ON `${TABLE_NAME}` (`master_id`, `recurrence_id`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_tasks_is_dirty",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"is_dirty"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_is_dirty` ON `${TABLE_NAME}` (`is_dirty`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_tasks_list_id_uid_recurrence_id",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"list_id",
|
||||||
|
"uid",
|
||||||
|
"recurrence_id"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tasks_list_id_uid_recurrence_id` ON `${TABLE_NAME}` (`list_id`, `uid`, `recurrence_id`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "task_lists",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"list_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "tasks",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"master_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "tasks",
|
||||||
|
"onDelete": "SET NULL",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"parent_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "task_alarms",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `task_id` INTEGER NOT NULL, `minutes_before` INTEGER NOT NULL, `reference` TEXT NOT NULL DEFAULT 'DUE', `message` TEXT, `uid` TEXT NOT NULL DEFAULT '', `raw` TEXT, FOREIGN KEY(`task_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "taskId",
|
||||||
|
"columnName": "task_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "minutesBefore",
|
||||||
|
"columnName": "minutes_before",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "reference",
|
||||||
|
"columnName": "reference",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "'DUE'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "message",
|
||||||
|
"columnName": "message",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "uid",
|
||||||
|
"columnName": "uid",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "''"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "raw",
|
||||||
|
"columnName": "raw",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_task_alarms_task_id",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"task_id"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_task_alarms_task_id` ON `${TABLE_NAME}` (`task_id`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "tasks",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"task_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "advanced_occurrences",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`copy_id` INTEGER NOT NULL, `master_id` INTEGER NOT NULL, `anchor` INTEGER NOT NULL, `advanced_to` INTEGER NOT NULL, `prior_dtstart` INTEGER, `prior_due` INTEGER, `prior_status` INTEGER NOT NULL, `prior_percent_complete` INTEGER, `prior_completed_at` INTEGER, `spent` INTEGER NOT NULL, `rule_after` TEXT, `dropped_exdates` TEXT NOT NULL DEFAULT '', `from_override` INTEGER NOT NULL, `undoable` INTEGER NOT NULL DEFAULT 1, `settled` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`copy_id`), FOREIGN KEY(`copy_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`master_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "copyId",
|
||||||
|
"columnName": "copy_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "masterId",
|
||||||
|
"columnName": "master_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "anchor",
|
||||||
|
"columnName": "anchor",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "advancedTo",
|
||||||
|
"columnName": "advanced_to",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "priorDtstart",
|
||||||
|
"columnName": "prior_dtstart",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "priorDue",
|
||||||
|
"columnName": "prior_due",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "priorStatus",
|
||||||
|
"columnName": "prior_status",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "priorPercentComplete",
|
||||||
|
"columnName": "prior_percent_complete",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "priorCompletedAt",
|
||||||
|
"columnName": "prior_completed_at",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "spent",
|
||||||
|
"columnName": "spent",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "ruleAfter",
|
||||||
|
"columnName": "rule_after",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "droppedExdates",
|
||||||
|
"columnName": "dropped_exdates",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "''"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "fromOverride",
|
||||||
|
"columnName": "from_override",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "undoable",
|
||||||
|
"columnName": "undoable",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "settled",
|
||||||
|
"columnName": "settled",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"copy_id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_advanced_occurrences_master_id_anchor",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"master_id",
|
||||||
|
"anchor"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_advanced_occurrences_master_id_anchor` ON `${TABLE_NAME}` (`master_id`, `anchor`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "tasks",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"copy_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "tasks",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"master_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "advanced_detached",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `copy_id` INTEGER NOT NULL, `anchor` INTEGER NOT NULL, `task_id` INTEGER, FOREIGN KEY(`copy_id`) REFERENCES `advanced_occurrences`(`copy_id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`task_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "copyId",
|
||||||
|
"columnName": "copy_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "anchor",
|
||||||
|
"columnName": "anchor",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "taskId",
|
||||||
|
"columnName": "task_id",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_advanced_detached_copy_id",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"copy_id"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_advanced_detached_copy_id` ON `${TABLE_NAME}` (`copy_id`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_advanced_detached_task_id",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"task_id"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_advanced_detached_task_id` ON `${TABLE_NAME}` (`task_id`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "advanced_occurrences",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"copy_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"copy_id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "tasks",
|
||||||
|
"onDelete": "SET NULL",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"task_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"setupQueries": [
|
||||||
|
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||||
|
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'fcb14ff68d892c72fa7459204276abb6')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+76
@@ -0,0 +1,76 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assume.assumeTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List order against a real external provider (#136). Skipped unless one is
|
||||||
|
* installed and its permissions are granted.
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class ExternalProviderListOrderTest {
|
||||||
|
|
||||||
|
private val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
private val resolver = ProviderResolver(AndroidProviderEnvironment(context)).apply {
|
||||||
|
storageMode = StorageMode.EXTERNAL
|
||||||
|
}
|
||||||
|
private val source = AndroidTasksDataSource(context, resolver)
|
||||||
|
private val created = mutableListOf<Long>()
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
val provider = resolver.resolve()
|
||||||
|
assumeTrue("no external tasks provider, or not granted", provider != null && resolver.hasPermission(provider))
|
||||||
|
listOf("Order A", "Order B", "Order C").forEach { created += source.createLocalList(it, 0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() = created.forEach { runCatching { source.deleteList(it) } }
|
||||||
|
|
||||||
|
private fun ours() = source.taskLists().map { it.id }.filter { it in created }
|
||||||
|
|
||||||
|
/** Waits until the provider has gone quiet, so a change seen afterwards is the test's own. */
|
||||||
|
private fun settle() {
|
||||||
|
var last = System.currentTimeMillis()
|
||||||
|
val observer = source.registerObserver { last = System.currentTimeMillis() }
|
||||||
|
while (System.currentTimeMillis() - last < 1_500) Thread.sleep(100)
|
||||||
|
observer.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aReorderIsKeptAndReported() {
|
||||||
|
val (a, b, c) = created
|
||||||
|
settle()
|
||||||
|
val changed = CountDownLatch(1)
|
||||||
|
val observer = source.registerObserver { changed.countDown() }
|
||||||
|
|
||||||
|
source.reorderLists(listOf(c, a, b))
|
||||||
|
|
||||||
|
assertThat(changed.await(5, TimeUnit.SECONDS)).isTrue()
|
||||||
|
observer.close()
|
||||||
|
assertThat(ours()).containsExactly(c, a, b).inOrder()
|
||||||
|
assertThat(AndroidTasksDataSource(context, resolver).taskLists().map { it.id }.filter { it in created })
|
||||||
|
.containsExactly(c, a, b).inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aDeletedListLeavesNoPositionBehind() {
|
||||||
|
val (a, b, c) = created
|
||||||
|
source.reorderLists(listOf(c, a, b))
|
||||||
|
|
||||||
|
source.deleteList(c)
|
||||||
|
created -= c
|
||||||
|
|
||||||
|
source.taskLists()
|
||||||
|
assertThat(source.storedListOrder().keys).doesNotContain(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
+361
@@ -0,0 +1,361 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
||||||
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertThrows
|
||||||
|
import org.junit.Assume.assumeTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Duration.Companion.hours
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recurrence against a real external provider (#135). Skipped unless one is
|
||||||
|
* installed and its permissions are granted, so it only runs where set up:
|
||||||
|
* `pm grant <app> org.tasks.permission.READ_TASKS` (and WRITE_TASKS).
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class ExternalProviderRecurrenceTest {
|
||||||
|
|
||||||
|
private val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
private val resolver = ProviderResolver(AndroidProviderEnvironment(context)).apply {
|
||||||
|
storageMode = StorageMode.EXTERNAL
|
||||||
|
}
|
||||||
|
private val source = AndroidTasksDataSource(context, resolver)
|
||||||
|
private var listId = 0L
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
val provider = resolver.resolve()
|
||||||
|
assumeTrue("no external tasks provider, or not granted", provider != null && resolver.hasPermission(provider))
|
||||||
|
listId = source.createLocalList("Recurrence test", 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
if (listId != 0L) source.deleteList(listId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dailyTask(): Long {
|
||||||
|
// Milliseconds and all, as the "start today" default gives.
|
||||||
|
val start = Clock.System.now() + 1.hours
|
||||||
|
return source.insertTask(
|
||||||
|
TaskForm(title = "Daily", listId = listId, start = start, due = start + 1.hours, rrule = "FREQ=DAILY"),
|
||||||
|
reminders = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun storedRule(taskId: Long): String? = context.contentResolver.query(
|
||||||
|
TasksContract.tasksUri(resolver.resolve()!!.authority),
|
||||||
|
arrayOf(Tasks.RRULE),
|
||||||
|
"${Tasks.ID} = ?",
|
||||||
|
arrayOf(taskId.toString()),
|
||||||
|
null,
|
||||||
|
)?.use { c -> if (c.moveToFirst()) c.getString(0) else null }
|
||||||
|
|
||||||
|
private fun setRule(taskId: Long, rule: String) {
|
||||||
|
context.contentResolver.update(
|
||||||
|
TasksContract.tasksUri(resolver.resolve()!!.authority),
|
||||||
|
ContentValues().apply { put(Tasks.RRULE, rule) },
|
||||||
|
"${Tasks.ID} = ?",
|
||||||
|
arrayOf(taskId.toString()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aSeriesReadsAsRecurringWithItsRule() {
|
||||||
|
val id = dailyTask()
|
||||||
|
|
||||||
|
val listed = source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id }.minBy { it.occurrenceStart!! }
|
||||||
|
assertThat(listed.isRecurring).isTrue()
|
||||||
|
assertThat(listed.isOccurrence).isTrue()
|
||||||
|
assertThat(listed.recurrenceRule).isEqualTo("FREQ=DAILY")
|
||||||
|
|
||||||
|
val opened = source.task(id)!!
|
||||||
|
assertThat(opened.recurrenceRule).isEqualTo("FREQ=DAILY")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun savingTheSeriesKeepsItsRule() {
|
||||||
|
val id = dailyTask()
|
||||||
|
val task = source.task(id)!!
|
||||||
|
|
||||||
|
source.updateSeries(
|
||||||
|
id,
|
||||||
|
task.occurrenceStart!!,
|
||||||
|
TaskForm(title = "Daily, renamed", listId = listId, start = task.start, due = task.due, rrule = task.recurrenceRule),
|
||||||
|
reminders = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(storedRule(id)).isEqualTo("FREQ=DAILY")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun completingAnOccurrenceMovesTheSeriesOn() {
|
||||||
|
val id = dailyTask()
|
||||||
|
val task = source.task(id)!!
|
||||||
|
|
||||||
|
source.setCompletedInstance(id, task.occurrenceStart!!, completed = true)
|
||||||
|
|
||||||
|
assertSeriesMovedOn(id, task.occurrenceStart!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aTickedOccurrenceReadsAsItsCopy() {
|
||||||
|
val id = dailyTask()
|
||||||
|
val anchor = source.task(id)!!.occurrenceStart!!
|
||||||
|
|
||||||
|
source.setCompletedInstance(id, anchor, completed = true)
|
||||||
|
|
||||||
|
val read = source.task(id, anchor)!!
|
||||||
|
assertThat(read.taskId).isNotEqualTo(id)
|
||||||
|
assertThat(read.isClosed).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun undoingATickMovesTheSeriesBack() {
|
||||||
|
val id = dailyTask()
|
||||||
|
val anchor = source.task(id)!!.occurrenceStart!!
|
||||||
|
source.setCompletedInstance(id, anchor, completed = true)
|
||||||
|
val copy = source.task(id, anchor)!!.taskId
|
||||||
|
|
||||||
|
source.setCompletedInstance(id, anchor, completed = false)
|
||||||
|
|
||||||
|
val read = source.task(id, anchor)!!
|
||||||
|
assertThat(read.taskId).isEqualTo(id)
|
||||||
|
assertThat(read.isClosed).isFalse()
|
||||||
|
assertThat(source.task(copy)).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reopeningTheCopyItselfMovesTheSeriesBack() {
|
||||||
|
val id = dailyTask()
|
||||||
|
val anchor = source.task(id)!!.occurrenceStart!!
|
||||||
|
source.setCompletedInstance(id, anchor, completed = true)
|
||||||
|
val copy = source.task(id, anchor)!!.taskId
|
||||||
|
|
||||||
|
source.setCompleted(copy, completed = false)
|
||||||
|
|
||||||
|
assertThat(source.task(id, anchor)!!.taskId).isEqualTo(id)
|
||||||
|
assertThat(source.task(copy)).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aSeriesChangedSinceTheTickReopensOnlyTheCopy() {
|
||||||
|
val id = dailyTask()
|
||||||
|
val anchor = source.task(id)!!.occurrenceStart!!
|
||||||
|
source.setCompletedInstance(id, anchor, completed = true)
|
||||||
|
val copy = source.task(id, anchor)!!.taskId
|
||||||
|
setRule(id, "FREQ=WEEKLY")
|
||||||
|
|
||||||
|
source.setCompletedInstance(id, anchor, completed = false)
|
||||||
|
|
||||||
|
assertThat(source.task(copy)!!.isClosed).isFalse()
|
||||||
|
assertThat(storedRule(id)).isEqualTo("FREQ=WEEKLY")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anOccurrenceThatIsGoneReadsAsNothing() {
|
||||||
|
val id = dailyTask()
|
||||||
|
val anchor = source.task(id)!!.occurrenceStart!!
|
||||||
|
|
||||||
|
assertThat(source.task(id, anchor + 30.hours)).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aSeriesStoredWithMillisecondsStillMovesOn() {
|
||||||
|
val id = legacySeries()
|
||||||
|
val task = source.task(id)!!
|
||||||
|
|
||||||
|
source.setCompletedInstance(id, task.occurrenceStart!!, completed = true)
|
||||||
|
|
||||||
|
assertSeriesMovedOn(id, task.occurrenceStart)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anUnknownOccurrenceDoesNotCloseTheSeries() {
|
||||||
|
val id = dailyTask()
|
||||||
|
val anchor = source.task(id)!!.occurrenceStart!!
|
||||||
|
|
||||||
|
assertThrows(TaskWriteFailedException::class.java) {
|
||||||
|
source.setCompletedInstance(id, anchor + 30.hours, completed = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThat(source.task(id)!!.isClosed).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun roundingALegacySeriesLeavesItUnmodified() {
|
||||||
|
val id = legacySeries()
|
||||||
|
val before = storedColumn(id, Tasks.LAST_MODIFIED)
|
||||||
|
|
||||||
|
source.task(id)
|
||||||
|
|
||||||
|
assertThat(storedStart(id)!! % 1000).isEqualTo(0L)
|
||||||
|
assertThat(storedColumn(id, Tasks.LAST_MODIFIED)).isEqualTo(before)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A daily series as builds before whole-second writes left it, already in sync with its server. */
|
||||||
|
private fun legacySeries(): Long {
|
||||||
|
val start = (Clock.System.now() + 1.hours).toEpochMilliseconds() / 1000 * 1000
|
||||||
|
return insertAsSyncAdapter(
|
||||||
|
Tasks.LIST_ID to listId,
|
||||||
|
Tasks.TITLE to "Old daily",
|
||||||
|
Tasks.DTSTART to start + 345,
|
||||||
|
Tasks.DUE to start + 3_600_345,
|
||||||
|
Tasks.TZ to "UTC",
|
||||||
|
Tasks.RRULE to "FREQ=DAILY",
|
||||||
|
).also {
|
||||||
|
// The app's own reads may already have run the one-time rounding.
|
||||||
|
context.getSharedPreferences("provider_migrations", Context.MODE_PRIVATE).edit().clear().commit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun insertAsSyncAdapter(vararg values: Pair<String, Any>): Long {
|
||||||
|
val uri = TasksContract.asSyncAdapter(
|
||||||
|
TasksContract.tasksUri(resolver.resolve()!!.authority),
|
||||||
|
TasksContract.LOCAL_ACCOUNT_NAME,
|
||||||
|
TasksContract.LOCAL_ACCOUNT_TYPE,
|
||||||
|
)
|
||||||
|
val cv = ContentValues().apply {
|
||||||
|
values.forEach { (k, v) -> if (v is Long) put(k, v) else put(k, v.toString()) }
|
||||||
|
}
|
||||||
|
return context.contentResolver.insert(uri, cv)!!.lastPathSegment!!.toLong()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Edits the series' first occurrence on its own and returns the override that makes. */
|
||||||
|
private fun overrideOf(id: Long): Task {
|
||||||
|
val task = source.task(id)!!
|
||||||
|
source.updateInstance(
|
||||||
|
id,
|
||||||
|
task.occurrenceStart!!,
|
||||||
|
TaskForm(title = "Just this once", listId = listId, start = task.start, due = task.due),
|
||||||
|
reminders = null,
|
||||||
|
)
|
||||||
|
return source.tasks(TaskQuery(listId = listId)).single { it.seriesId == id && it.taskId != id }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anOverrideBelongsToItsSeries() {
|
||||||
|
val id = dailyTask()
|
||||||
|
val anchor = source.task(id)!!.occurrenceStart
|
||||||
|
|
||||||
|
val override = overrideOf(id)
|
||||||
|
|
||||||
|
assertThat(override.title).isEqualTo("Just this once")
|
||||||
|
assertThat(override.isOccurrence).isTrue()
|
||||||
|
assertThat(override.occurrenceStart).isEqualTo(anchor)
|
||||||
|
assertThat(override.recurrenceRule).isEqualTo("FREQ=DAILY")
|
||||||
|
assertThat(source.task(override.taskId, override.occurrenceStart)?.taskId).isEqualTo(override.taskId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anAllDayDueAddedToTheSeriesStaysOnItsDay() {
|
||||||
|
val day = Clock.System.now().toEpochMilliseconds() / 86_400_000 * 86_400_000
|
||||||
|
val id = source.insertTask(
|
||||||
|
TaskForm(title = "All day", listId = listId, start = Instant.fromEpochMilliseconds(day), isAllDay = true, rrule = "FREQ=DAILY"),
|
||||||
|
reminders = null,
|
||||||
|
)
|
||||||
|
val task = source.task(id)!!
|
||||||
|
// Local midnight the next day, as the date picker hands it over.
|
||||||
|
val localMidnight = Instant.fromEpochMilliseconds(day + 86_400_000 - java.util.TimeZone.getDefault().getOffset(day))
|
||||||
|
|
||||||
|
source.updateSeries(
|
||||||
|
id,
|
||||||
|
task.occurrenceStart!!,
|
||||||
|
TaskForm(title = "All day", listId = listId, start = task.start, due = localMidnight, isAllDay = true, rrule = "FREQ=DAILY"),
|
||||||
|
reminders = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(storedColumn(id, Tasks.DUE)!!.toLong() % 86_400_000).isEqualTo(0L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun savingAllFromAnOverrideKeepsTheSeriesWhereItIs() {
|
||||||
|
val id = dailyTask()
|
||||||
|
val before = storedStart(id)
|
||||||
|
val override = overrideOf(id)
|
||||||
|
|
||||||
|
source.updateSeries(
|
||||||
|
id,
|
||||||
|
override.occurrenceStart!!,
|
||||||
|
TaskForm(title = "Renamed", listId = listId, start = override.start, due = override.due, rrule = override.recurrenceRule),
|
||||||
|
reminders = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(storedStart(id)).isEqualTo(before)
|
||||||
|
assertThat(storedRule(id)).isEqualTo("FREQ=DAILY")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deletingAnOverrideExcludesItsOccurrence() {
|
||||||
|
val id = dailyTask()
|
||||||
|
val override = overrideOf(id)
|
||||||
|
|
||||||
|
source.deleteOverride(override.taskId, override.occurrenceStart!!)
|
||||||
|
|
||||||
|
// Excluded on the master, not just the override dropped.
|
||||||
|
assertThat(storedColumn(id, Tasks.EXDATE)).isNotNull()
|
||||||
|
val open = source.tasks(TaskQuery(listId = listId)).filter { it.seriesId == id }
|
||||||
|
assertThat(open.map { it.occurrenceStart }).doesNotContain(override.occurrenceStart)
|
||||||
|
assertThat(storedRule(id)).isEqualTo("FREQ=DAILY")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun editingAnOverrideAgainKeepsItsNewTagsAndReminders() {
|
||||||
|
val id = dailyTask()
|
||||||
|
val override = overrideOf(id)
|
||||||
|
val form = TaskForm(
|
||||||
|
title = override.title,
|
||||||
|
listId = listId,
|
||||||
|
start = override.start,
|
||||||
|
due = override.due,
|
||||||
|
tags = listOf("urgent"),
|
||||||
|
reminders = listOf(TaskReminder(minutesBefore = 15)),
|
||||||
|
)
|
||||||
|
|
||||||
|
source.updateInstance(override.taskId, override.occurrenceStart!!, form, form.reminders)
|
||||||
|
|
||||||
|
assertThat(source.task(override.taskId)!!.tags).containsExactly("urgent")
|
||||||
|
assertThat(source.reminders()[override.taskId]).containsExactly(TaskReminder(minutesBefore = 15))
|
||||||
|
assertThat(source.task(id)?.tags.orEmpty()).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anOccurrenceIsFoundByItsAnchorToTheSecond() {
|
||||||
|
val id = dailyTask()
|
||||||
|
val override = overrideOf(id)
|
||||||
|
|
||||||
|
val found = source.task(id, override.occurrenceStart!! + 345.milliseconds)
|
||||||
|
|
||||||
|
assertThat(found?.taskId).isEqualTo(override.taskId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun storedStart(taskId: Long): Long? = storedColumn(taskId, Tasks.DTSTART)?.toLong()
|
||||||
|
|
||||||
|
private fun storedColumn(taskId: Long, column: String): String? = context.contentResolver.query(
|
||||||
|
TasksContract.tasksUri(resolver.resolve()!!.authority),
|
||||||
|
arrayOf(column),
|
||||||
|
"${Tasks.ID} = ?",
|
||||||
|
arrayOf(taskId.toString()),
|
||||||
|
null,
|
||||||
|
)?.use { c -> if (c.moveToFirst()) c.getString(0) else null }
|
||||||
|
|
||||||
|
private fun assertSeriesMovedOn(id: Long, completed: Instant) {
|
||||||
|
assertThat(storedRule(id)).isNotNull()
|
||||||
|
val open = source.tasks(TaskQuery(listId = listId)).filter { it.seriesId == id }
|
||||||
|
assertThat(open).isNotEmpty()
|
||||||
|
assertThat(open.minOf { it.occurrenceStart!! }).isGreaterThan(completed)
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
@@ -124,6 +124,7 @@ class AgendulaTaskProviderTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun aBadWriteReachesTheClientAsIllegalArgument() {
|
fun aBadWriteReachesTheClientAsIllegalArgument() {
|
||||||
|
assumeTrue("Agendula is running on an external provider", ownStore())
|
||||||
// No list has this id, so nothing in the app's real store is touched.
|
// No list has this id, so nothing in the app's real store is touched.
|
||||||
val values = ContentValues().apply {
|
val values = ContentValues().apply {
|
||||||
put("list_id", Long.MAX_VALUE)
|
put("list_id", Long.MAX_VALUE)
|
||||||
@@ -135,6 +136,7 @@ class AgendulaTaskProviderTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun aFailingBatchWritesNothing() {
|
fun aFailingBatchWritesNothing() {
|
||||||
|
assumeTrue("Agendula is running on an external provider", ownStore())
|
||||||
val missingList = Long.MAX_VALUE
|
val missingList = Long.MAX_VALUE
|
||||||
val operations = arrayListOf(
|
val operations = arrayListOf(
|
||||||
ContentProviderOperation.newInsert(uri("tasks")).withValue("list_id", missingList).withValue("title", "a").build(),
|
ContentProviderOperation.newInsert(uri("tasks")).withValue("list_id", missingList).withValue("title", "a").build(),
|
||||||
@@ -145,6 +147,9 @@ class AgendulaTaskProviderTest {
|
|||||||
.use { assertThat(it.count).isEqualTo(0) }
|
.use { assertThat(it.count).isEqualTo(0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether the app's own store is active: only then does the provider take writes at all. */
|
||||||
|
private fun ownStore() = runCatching { resolver.delete(uri("tasks"), "_id = -1", null) }.isSuccess
|
||||||
|
|
||||||
/** stdout and stderr of [command]; the denial lands on stderr. */
|
/** stdout and stderr of [command]; the denial lands on stderr. */
|
||||||
private fun shell(command: String): String {
|
private fun shell(command: String): String {
|
||||||
val (stdout, stdin, stderr) = InstrumentationRegistry.getInstrumentation().uiAutomation
|
val (stdout, stdin, stderr) = InstrumentationRegistry.getInstrumentation().uiAutomation
|
||||||
|
|||||||
+18
-2
@@ -734,9 +734,11 @@ class ProviderWriterTest {
|
|||||||
val series = db.tasks().insert(
|
val series = db.tasks().insert(
|
||||||
TaskEntity(listId = home, uid = "f", title = "Floating", due = today, rrule = "FREQ=DAILY;COUNT=3", timezone = null),
|
TaskEntity(listId = home, uid = "f", title = "Floating", due = today, rrule = "FREQ=DAILY;COUNT=3", timezone = null),
|
||||||
)
|
)
|
||||||
val row = readRow(ProviderTable.INSTANCES, instanceId(today))
|
// The second occurrence: ticking the first moves the series on instead.
|
||||||
|
val second = today + 1.days
|
||||||
|
val row = readRow(ProviderTable.INSTANCES, instanceId(second))
|
||||||
|
|
||||||
writer.update(ProviderTable.INSTANCES, row + (Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
writer.update(ProviderTable.INSTANCES, row + (Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(second)), null)
|
||||||
writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "Still floating"), byId(ProviderTable.TASKS, series), null)
|
writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "Still floating"), byId(ProviderTable.TASKS, series), null)
|
||||||
|
|
||||||
assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.COMPLETED)
|
assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.COMPLETED)
|
||||||
@@ -795,6 +797,20 @@ class ProviderWriterTest {
|
|||||||
|
|
||||||
private fun store() = RoomTasksDataSource(db)
|
private fun store() = RoomTasksDataSource(db)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reopeningATickedOccurrencesCopyMovesTheSeriesBack() {
|
||||||
|
val series = db.tasks().insert(
|
||||||
|
TaskEntity(listId = home, uid = "w", title = "Weekly", dtstart = today, rrule = "FREQ=WEEKLY", timezone = "UTC"),
|
||||||
|
)
|
||||||
|
store().setCompletedInstance(series, today, true)
|
||||||
|
val copy = db.tasks().allIn(home).single { it.id != series }
|
||||||
|
|
||||||
|
writer.update(ProviderTable.TASKS, mapOf(Tasks.STATUS to 0), byId(ProviderTable.TASKS, copy.id), null)
|
||||||
|
|
||||||
|
assertThat(task(series)!!.dtstart).isEqualTo(today)
|
||||||
|
assertThat(task(copy.id)).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
/** Every column of one published row, as a client that read it would write it back. */
|
/** Every column of one published row, as a client that read it would write it back. */
|
||||||
private fun readRow(table: ProviderTable, id: Long): Map<String, Any?> {
|
private fun readRow(table: ProviderTable, id: Long): Map<String, Any?> {
|
||||||
val columns = table.columns.map { it.first }
|
val columns = table.columns.map { it.first }
|
||||||
|
|||||||
+625
-5
@@ -211,7 +211,8 @@ class RoomTasksDataSourceTest {
|
|||||||
@Test
|
@Test
|
||||||
fun completingOneOccurrenceLeavesTheRestOfTheSeriesOpen() {
|
fun completingOneOccurrenceLeavesTheRestOfTheSeriesOpen() {
|
||||||
val id = source.insertTask(form(title = "Water the plants"))
|
val id = source.insertTask(form(title = "Water the plants"))
|
||||||
makeRecurring(id, now)
|
// Overdue occurrences before it, so it is not the first and forks an override.
|
||||||
|
makeRecurring(id, now - 14.days)
|
||||||
val open = { source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id } }
|
val open = { source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id } }
|
||||||
val before = open()
|
val before = open()
|
||||||
val target = before.first { it.distanceFromCurrent == 0 }
|
val target = before.first { it.distanceFromCurrent == 0 }
|
||||||
@@ -237,7 +238,8 @@ class RoomTasksDataSourceTest {
|
|||||||
@Test
|
@Test
|
||||||
fun reopeningACompletedOccurrenceReusesItsOverride() {
|
fun reopeningACompletedOccurrenceReusesItsOverride() {
|
||||||
val id = source.insertTask(form(title = "Water the plants"))
|
val id = source.insertTask(form(title = "Water the plants"))
|
||||||
makeRecurring(id, now)
|
// Overdue occurrences before it, so it is not the first and forks an override.
|
||||||
|
makeRecurring(id, now - 14.days)
|
||||||
val target = source.tasks(TaskQuery(listId = listId))
|
val target = source.tasks(TaskQuery(listId = listId))
|
||||||
.first { it.taskId == id && it.distanceFromCurrent == 0 }
|
.first { it.taskId == id && it.distanceFromCurrent == 0 }
|
||||||
|
|
||||||
@@ -254,7 +256,8 @@ class RoomTasksDataSourceTest {
|
|||||||
@Test
|
@Test
|
||||||
fun cancellingOneOccurrenceLeavesTheRestOfTheSeriesOpen() {
|
fun cancellingOneOccurrenceLeavesTheRestOfTheSeriesOpen() {
|
||||||
val id = source.insertTask(form(title = "Water the plants"))
|
val id = source.insertTask(form(title = "Water the plants"))
|
||||||
makeRecurring(id, now)
|
// Overdue occurrences before it, so it is not the first and forks an override.
|
||||||
|
makeRecurring(id, now - 14.days)
|
||||||
val target = source.tasks(TaskQuery(listId = listId))
|
val target = source.tasks(TaskQuery(listId = listId))
|
||||||
.first { it.taskId == id && it.distanceFromCurrent == 0 }
|
.first { it.taskId == id && it.distanceFromCurrent == 0 }
|
||||||
|
|
||||||
@@ -269,6 +272,621 @@ class RoomTasksDataSourceTest {
|
|||||||
assertThat(db.tasks().override(id, target.occurrenceStart)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
assertThat(db.tasks().override(id, target.occurrenceStart)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun completingTheFirstOccurrenceMovesTheSeriesOnAndKeepsACopy() {
|
||||||
|
val at = now
|
||||||
|
val id = source.insertTask(form(title = "Water the plants"))
|
||||||
|
makeRecurring(id, at, rule = "FREQ=WEEKLY;COUNT=4")
|
||||||
|
|
||||||
|
source.setCompletedInstance(id, at, completed = true)
|
||||||
|
|
||||||
|
val master = db.tasks().entity(id)!!
|
||||||
|
assertThat(master.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||||
|
assertThat(master.dtstart).isEqualTo(at + 7.days)
|
||||||
|
assertThat(master.due).isEqualTo(at + 8.days)
|
||||||
|
assertThat(master.rrule).isEqualTo("FREQ=WEEKLY;COUNT=3")
|
||||||
|
assertThat(master.isDirty).isTrue()
|
||||||
|
assertThat(db.tasks().overrides(id)).isEmpty()
|
||||||
|
val copy = db.tasks().allIn(listId).single { it.id != id }
|
||||||
|
assertThat(copy.status).isEqualTo(TaskStatus.COMPLETED)
|
||||||
|
assertThat(copy.uid).isNotEqualTo(master.uid)
|
||||||
|
assertThat(copy.rrule).isNull()
|
||||||
|
assertThat(copy.masterId).isNull()
|
||||||
|
assertThat(copy.dtstart).isEqualTo(at)
|
||||||
|
assertThat(copy.title).isEqualTo("Water the plants")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reopeningTheOccurrenceMovesTheSeriesBack() {
|
||||||
|
val at = now
|
||||||
|
val id = source.insertTask(form(title = "Water the plants"))
|
||||||
|
makeRecurring(id, at, rule = "FREQ=WEEKLY;COUNT=4")
|
||||||
|
source.setCompletedInstance(id, at, completed = true)
|
||||||
|
|
||||||
|
source.setCompletedInstance(id, at, completed = false)
|
||||||
|
|
||||||
|
val master = db.tasks().entity(id)!!
|
||||||
|
assertThat(master.dtstart).isEqualTo(at)
|
||||||
|
assertThat(master.rrule).isEqualTo("FREQ=WEEKLY;COUNT=4")
|
||||||
|
assertThat(db.tasks().allIn(listId).filter { !it.isDeleted }.map { it.id }).containsExactly(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun completingAnEditedFirstOccurrenceKeepsItsEdits() {
|
||||||
|
val at = now
|
||||||
|
val id = source.insertTask(form(title = "Water the plants"))
|
||||||
|
makeRecurring(id, at)
|
||||||
|
source.updateInstance(id, at, TaskForm(title = "Water the cactus", listId = listId, start = at, due = at + 1.days), reminders = null)
|
||||||
|
val override = db.tasks().override(id, at)!!
|
||||||
|
|
||||||
|
source.setCompletedInstance(override.id, at, completed = true)
|
||||||
|
|
||||||
|
assertThat(db.tasks().overrides(id)).isEmpty()
|
||||||
|
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 7.days)
|
||||||
|
val copy = db.tasks().allIn(listId).single { it.id != id }
|
||||||
|
assertThat(copy.title).isEqualTo("Water the cactus")
|
||||||
|
assertThat(copy.status).isEqualTo(TaskStatus.COMPLETED)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun completingTheLastOccurrenceClosesTheSeries() {
|
||||||
|
val at = now
|
||||||
|
val id = source.insertTask(form(title = "Water the plants"))
|
||||||
|
makeRecurring(id, at, rule = "FREQ=WEEKLY;COUNT=1")
|
||||||
|
|
||||||
|
source.setCompletedInstance(id, at, completed = true)
|
||||||
|
|
||||||
|
assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.COMPLETED)
|
||||||
|
assertThat(db.tasks().allIn(listId).map { it.id }).containsExactly(id)
|
||||||
|
|
||||||
|
source.setCompletedInstance(id, at, completed = false)
|
||||||
|
|
||||||
|
assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cancellingTheFirstOccurrenceMovesTheSeriesOn() {
|
||||||
|
val at = now
|
||||||
|
val id = source.insertTask(form(title = "Water the plants"))
|
||||||
|
makeRecurring(id, at)
|
||||||
|
|
||||||
|
source.setCancelledInstance(id, at, cancelled = true)
|
||||||
|
|
||||||
|
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 7.days)
|
||||||
|
assertThat(db.tasks().allIn(listId).single { it.id != id }.status).isEqualTo(TaskStatus.CANCELLED)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aSeriesWhoseStartWasDeletedMovesPastTheTickedOne() {
|
||||||
|
val at = now
|
||||||
|
val id = source.insertTask(form(title = "Weekly"))
|
||||||
|
makeRecurring(id, at)
|
||||||
|
source.deleteOccurrence(id, at)
|
||||||
|
|
||||||
|
source.setCompletedInstance(id, at + 7.days, completed = true)
|
||||||
|
|
||||||
|
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 14.days)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun countSpendsExcludedOccurrencesAndReopeningRestoresThem() {
|
||||||
|
val at = now
|
||||||
|
val id = source.insertTask(form(title = "Daily"))
|
||||||
|
makeRecurring(id, at, rule = "FREQ=DAILY;COUNT=4")
|
||||||
|
source.deleteOccurrence(id, at + 1.days)
|
||||||
|
val before = db.tasks().entity(id)!!
|
||||||
|
|
||||||
|
source.setCompletedInstance(id, at, completed = true)
|
||||||
|
|
||||||
|
val moved = db.tasks().entity(id)!!
|
||||||
|
assertThat(moved.dtstart).isEqualTo(at + 2.days)
|
||||||
|
assertThat(moved.rrule).isEqualTo("FREQ=DAILY;COUNT=2")
|
||||||
|
|
||||||
|
source.setCompletedInstance(id, at, completed = false)
|
||||||
|
|
||||||
|
val back = db.tasks().entity(id)!!
|
||||||
|
assertThat(back.dtstart).isEqualTo(before.dtstart)
|
||||||
|
assertThat(back.rrule).isEqualTo(before.rrule)
|
||||||
|
assertThat(back.exdate).isEqualTo(before.exdate)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reopeningTheCopyItselfMovesTheSeriesBack() {
|
||||||
|
val at = now
|
||||||
|
val id = source.insertTask(form(title = "Weekly"))
|
||||||
|
makeRecurring(id, at)
|
||||||
|
source.setCompletedInstance(id, at, completed = true)
|
||||||
|
val copy = db.tasks().allIn(listId).single { it.id != id }
|
||||||
|
|
||||||
|
source.setCompleted(copy.id, completed = false)
|
||||||
|
|
||||||
|
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at)
|
||||||
|
assertThat(db.tasks().allIn(listId).filter { !it.isDeleted }.map { it.id }).containsExactly(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun undoingAnEditedOccurrenceBringsItsOverrideBack() {
|
||||||
|
val at = now
|
||||||
|
val id = source.insertTask(form(title = "Weekly"))
|
||||||
|
makeRecurring(id, at)
|
||||||
|
source.updateInstance(id, at, TaskForm(title = "Just this once", listId = listId, start = at, due = at + 1.days), reminders = null)
|
||||||
|
val override = db.tasks().override(id, at)!!
|
||||||
|
source.setCompletedInstance(override.id, at, completed = true)
|
||||||
|
|
||||||
|
source.setCompletedInstance(override.id, at, completed = false)
|
||||||
|
|
||||||
|
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at)
|
||||||
|
val back = db.tasks().override(id, at)!!
|
||||||
|
assertThat(back.title).isEqualTo("Just this once")
|
||||||
|
assertThat(back.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aStaleTickOfAnOccurrenceMovedPastForksNothing() {
|
||||||
|
val at = now
|
||||||
|
val id = source.insertTask(form(title = "Weekly"))
|
||||||
|
makeRecurring(id, at)
|
||||||
|
source.setCompletedInstance(id, at, completed = true)
|
||||||
|
|
||||||
|
source.setCompletedInstance(id, at, completed = true)
|
||||||
|
|
||||||
|
assertThat(db.tasks().overrides(id)).isEmpty()
|
||||||
|
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 7.days)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun occurrencesClosedBeforeTheUpdateNoLongerHoldTheSeriesBack() {
|
||||||
|
val at = now
|
||||||
|
val id = source.insertTask(form(title = "Weekly"))
|
||||||
|
makeRecurring(id, at)
|
||||||
|
val master = db.tasks().entity(id)!!
|
||||||
|
db.tasks().insert(
|
||||||
|
master.copy(id = 0, masterId = id, recurrenceId = at, rrule = null, status = TaskStatus.COMPLETED, completedAt = at),
|
||||||
|
)
|
||||||
|
|
||||||
|
source.setCompletedInstance(id, at + 7.days, completed = true)
|
||||||
|
|
||||||
|
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 14.days)
|
||||||
|
assertThat(db.tasks().overrides(id)).isEmpty()
|
||||||
|
assertThat(db.tasks().allIn(listId).count { it.id != id && it.status == TaskStatus.COMPLETED }).isEqualTo(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theTickedOccurrenceStillResolvesToItsCopy() {
|
||||||
|
val at = now
|
||||||
|
val id = source.insertTask(form(title = "Weekly"))
|
||||||
|
makeRecurring(id, at)
|
||||||
|
val occurrence = source.task(id, at)!!.occurrenceStart!!
|
||||||
|
|
||||||
|
source.setCompletedInstance(id, occurrence, completed = true)
|
||||||
|
|
||||||
|
val found = source.task(id, occurrence)!!
|
||||||
|
assertThat(found.taskId).isNotEqualTo(id)
|
||||||
|
assertThat(found.isCompleted).isTrue()
|
||||||
|
assertThat(found.occurrenceStart).isEqualTo(occurrence)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theNextOccurrenceStartsWithoutTheLastOnesProgress() {
|
||||||
|
val at = now
|
||||||
|
val id = source.insertTask(form(title = "Weekly"))
|
||||||
|
makeRecurring(id, at)
|
||||||
|
db.tasks().update(db.tasks().entity(id)!!.copy(status = TaskStatus.IN_PROCESS, percentComplete = 60))
|
||||||
|
|
||||||
|
source.setCompletedInstance(id, at, completed = true)
|
||||||
|
|
||||||
|
val master = db.tasks().entity(id)!!
|
||||||
|
assertThat(master.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||||
|
assertThat(master.percentComplete).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theCopyOfAnEditedOccurrenceKeepsItsOwnProperties() {
|
||||||
|
val at = now
|
||||||
|
val id = source.insertTask(form(title = "Weekly"))
|
||||||
|
makeRecurring(id, at)
|
||||||
|
val master = db.tasks().entity(id)!!
|
||||||
|
val override = db.tasks().insert(
|
||||||
|
master.copy(id = 0, masterId = id, recurrenceId = at, rrule = null, unknownProperties = "COMMENT:only this one"),
|
||||||
|
)
|
||||||
|
|
||||||
|
source.setCompletedInstance(override, at, completed = true)
|
||||||
|
|
||||||
|
assertThat(db.tasks().entity(override)!!.unknownProperties!!.lines()).contains("COMMENT:only this one")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aRuleWithAGapOfMoreThanTenYearsKeepsGoing() {
|
||||||
|
val at = now
|
||||||
|
val id = source.insertTask(form(title = "Rare"))
|
||||||
|
makeRecurring(id, at, rule = "FREQ=YEARLY;INTERVAL=11")
|
||||||
|
|
||||||
|
source.setCompletedInstance(id, at, completed = true)
|
||||||
|
|
||||||
|
val master = db.tasks().entity(id)!!
|
||||||
|
assertThat(master.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||||
|
assertThat(master.dtstart!!).isGreaterThan(at + (366 * 10).days)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reopeningKeepsWhatChangedOnTheSeriesSince() {
|
||||||
|
val at = now
|
||||||
|
val id = source.insertTask(form(title = "Weekly"))
|
||||||
|
makeRecurring(id, at)
|
||||||
|
source.setCompletedInstance(id, at, completed = true)
|
||||||
|
source.deleteOccurrence(id, at + 28.days)
|
||||||
|
|
||||||
|
source.setCompletedInstance(id, at, completed = false)
|
||||||
|
|
||||||
|
val master = db.tasks().entity(id)!!
|
||||||
|
assertThat(master.dtstart).isEqualTo(at)
|
||||||
|
assertThat(master.exdate).isNotNull()
|
||||||
|
assertThat(source.tasks(TaskQuery(listId = listId)).map { it.occurrenceStart?.epochSeconds })
|
||||||
|
.doesNotContain((at + 28.days).epochSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aSeriesWithExtraDatesKeepsTheOverrideModel() {
|
||||||
|
val at = now
|
||||||
|
val id = source.insertTask(form(title = "Weekly"))
|
||||||
|
makeRecurring(id, at)
|
||||||
|
db.tasks().update(db.tasks().entity(id)!!.copy(rdate = "20991231T090000Z"))
|
||||||
|
|
||||||
|
source.setCompletedInstance(id, at, completed = true)
|
||||||
|
|
||||||
|
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at)
|
||||||
|
assertThat(db.tasks().overrides(id).single().status).isEqualTo(TaskStatus.COMPLETED)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aDeletedSeriesIsNotBroughtBackByItsCopy() {
|
||||||
|
val at = now
|
||||||
|
val work = syncedList()
|
||||||
|
val id = source.insertTask(form(title = "Weekly").copy(listId = work))
|
||||||
|
makeRecurring(id, at)
|
||||||
|
source.setCompletedInstance(id, at, completed = true)
|
||||||
|
val copy = db.tasks().allIn(work).single { it.id != id }
|
||||||
|
source.deleteWithSubtasks(id)
|
||||||
|
|
||||||
|
source.setCompleted(copy.id, completed = false)
|
||||||
|
|
||||||
|
assertThat(db.tasks().entity(copy.id)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||||
|
assertThat(db.tasks().entity(copy.id)!!.isDeleted).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aSeriesEndedOnItsLastOpenOccurrenceReopens() {
|
||||||
|
val at = now
|
||||||
|
val id = source.insertTask(form(title = "Three"))
|
||||||
|
makeRecurring(id, at, rule = "FREQ=WEEKLY;COUNT=3")
|
||||||
|
val master = db.tasks().entity(id)!!
|
||||||
|
listOf(at, at + 7.days).forEach { done ->
|
||||||
|
db.tasks().insert(master.copy(id = 0, masterId = id, recurrenceId = done, rrule = null, status = TaskStatus.COMPLETED))
|
||||||
|
}
|
||||||
|
|
||||||
|
source.setCompletedInstance(id, at + 14.days, completed = true)
|
||||||
|
assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.COMPLETED)
|
||||||
|
// Nothing to move past: the closed occurrences stay the series' own.
|
||||||
|
assertThat(db.tasks().overrides(id)).hasSize(2)
|
||||||
|
|
||||||
|
source.setCompletedInstance(id, at + 14.days, completed = false)
|
||||||
|
assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reopeningAnOlderCopyReopensItOnItsOwn() {
|
||||||
|
val at = now
|
||||||
|
val id = source.insertTask(form(title = "Weekly"))
|
||||||
|
makeRecurring(id, at)
|
||||||
|
source.setCompletedInstance(id, at, completed = true)
|
||||||
|
source.setCompletedInstance(id, at + 7.days, completed = true)
|
||||||
|
|
||||||
|
source.setCompletedInstance(id, at, completed = false)
|
||||||
|
|
||||||
|
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 14.days)
|
||||||
|
val reopened = db.tasks().allIn(listId).single { it.id != id && it.dtstart == at }
|
||||||
|
assertThat(reopened.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||||
|
assertThat(db.advancedOccurrences().forCopy(reopened.id)!!.undoable).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aSeriesMovesPastOccurrencesAlreadyClosedAfterIt() {
|
||||||
|
val at = now
|
||||||
|
val id = source.insertTask(form(title = "Weekly"))
|
||||||
|
makeRecurring(id, at)
|
||||||
|
val master = db.tasks().entity(id)!!
|
||||||
|
db.tasks().insert(master.copy(id = 0, masterId = id, recurrenceId = at + 7.days, rrule = null, status = TaskStatus.COMPLETED))
|
||||||
|
|
||||||
|
source.setCompletedInstance(id, at, completed = true)
|
||||||
|
|
||||||
|
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 14.days)
|
||||||
|
assertThat(db.tasks().overrides(id)).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aDetachedOccurrenceDeletedSinceStaysExcluded() {
|
||||||
|
val at = now
|
||||||
|
val id = source.insertTask(form(title = "Weekly"))
|
||||||
|
makeRecurring(id, at)
|
||||||
|
val master = db.tasks().entity(id)!!
|
||||||
|
val earlier = db.tasks().insert(master.copy(id = 0, masterId = id, recurrenceId = at, rrule = null, status = TaskStatus.COMPLETED))
|
||||||
|
source.setCompletedInstance(id, at + 7.days, completed = true)
|
||||||
|
source.deleteTask(earlier)
|
||||||
|
|
||||||
|
source.setCompletedInstance(id, at + 7.days, completed = false)
|
||||||
|
|
||||||
|
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at)
|
||||||
|
assertThat(source.tasks(TaskQuery(listId = listId)).map { it.occurrenceStart?.epochSeconds })
|
||||||
|
.doesNotContain(at.epochSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aNestedComponentKeepsItsOwnUid() {
|
||||||
|
val at = now
|
||||||
|
val id = source.insertTask(form(title = "Weekly"))
|
||||||
|
makeRecurring(id, at)
|
||||||
|
val master = db.tasks().entity(id)!!
|
||||||
|
val override = db.tasks().insert(
|
||||||
|
master.copy(
|
||||||
|
id = 0, masterId = id, recurrenceId = at, rrule = null,
|
||||||
|
unknownProperties = "BEGIN:VALARM\r\nUID:alarm-1\r\nACTION:AUDIO\r\nEND:VALARM",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
source.setCompletedInstance(override, at, completed = true)
|
||||||
|
|
||||||
|
assertThat(db.tasks().entity(override)!!.unknownProperties!!.lines()).contains("UID:alarm-1")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reopeningTheCopyInTheEditorMovesTheSeriesBack() {
|
||||||
|
val at = now
|
||||||
|
val id = source.insertTask(form(title = "Weekly"))
|
||||||
|
makeRecurring(id, at)
|
||||||
|
source.setCompletedInstance(id, at, completed = true)
|
||||||
|
val copy = db.tasks().allIn(listId).single { it.id != id }
|
||||||
|
|
||||||
|
source.updateTask(
|
||||||
|
copy.id,
|
||||||
|
TaskForm(title = copy.title.orEmpty(), listId = listId, start = copy.dtstart, due = copy.due, percentComplete = 0),
|
||||||
|
reminders = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anEditedCopyReopensAsATaskOfItsOwn() {
|
||||||
|
val at = now
|
||||||
|
val id = source.insertTask(form(title = "Weekly"))
|
||||||
|
makeRecurring(id, at)
|
||||||
|
source.setCompletedInstance(id, at, completed = true)
|
||||||
|
val copy = db.tasks().allIn(listId).single { it.id != id }
|
||||||
|
db.tasks().update(copy.copy(description = "notes"))
|
||||||
|
source.updateTask(copy.id, TaskForm(title = "Renamed", listId = listId, start = copy.dtstart, due = copy.due, percentComplete = 100), null)
|
||||||
|
|
||||||
|
source.setCompleted(copy.id, completed = false)
|
||||||
|
|
||||||
|
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 7.days)
|
||||||
|
assertThat(db.tasks().entity(copy.id)!!.title).isEqualTo("Renamed")
|
||||||
|
assertThat(db.tasks().entity(copy.id)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aCopyWithASubtaskReopensOnItsOwn() {
|
||||||
|
val at = now
|
||||||
|
val id = source.insertTask(form(title = "Weekly"))
|
||||||
|
makeRecurring(id, at)
|
||||||
|
source.setCompletedInstance(id, at, completed = true)
|
||||||
|
val copy = db.tasks().allIn(listId).single { it.id != id }
|
||||||
|
source.insertTask(form(title = "Part").copy(parentId = copy.id))
|
||||||
|
|
||||||
|
source.setCompleted(copy.id, completed = false)
|
||||||
|
|
||||||
|
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 7.days)
|
||||||
|
assertThat(db.tasks().entity(copy.id)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aRecurringSubtaskKeepsTheOverrideModel() {
|
||||||
|
val at = now
|
||||||
|
val parent = source.insertTask(form(title = "Parent"))
|
||||||
|
val id = source.insertTask(form(title = "Daily part").copy(parentId = parent))
|
||||||
|
makeRecurring(id, at, rule = "FREQ=DAILY")
|
||||||
|
|
||||||
|
source.setCompletedInstance(id, at, completed = true)
|
||||||
|
|
||||||
|
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at)
|
||||||
|
assertThat(db.tasks().overrides(id)).hasSize(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aSeriesReshapedSinceIsNotMovedBack() {
|
||||||
|
val at = now
|
||||||
|
val id = source.insertTask(form(title = "Ten"))
|
||||||
|
makeRecurring(id, at, rule = "FREQ=WEEKLY;COUNT=10")
|
||||||
|
source.setCompletedInstance(id, at, completed = true)
|
||||||
|
db.tasks().update(db.tasks().entity(id)!!.copy(rrule = "FREQ=DAILY;COUNT=5"))
|
||||||
|
|
||||||
|
source.setCompletedInstance(id, at, completed = false)
|
||||||
|
|
||||||
|
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 7.days)
|
||||||
|
assertThat(db.tasks().entity(id)!!.rrule).isEqualTo("FREQ=DAILY;COUNT=5")
|
||||||
|
assertThat(db.tasks().allIn(listId).single { it.id != id }.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tickingTheSameOccurrenceAgainKeepsOneRecord() {
|
||||||
|
val at = now
|
||||||
|
val id = source.insertTask(form(title = "Weekly"))
|
||||||
|
makeRecurring(id, at)
|
||||||
|
source.setCompletedInstance(id, at, completed = true)
|
||||||
|
source.setCompletedInstance(id, at, completed = false)
|
||||||
|
|
||||||
|
source.setCompletedInstance(id, at, completed = true)
|
||||||
|
|
||||||
|
assertThat(db.advancedOccurrences().forMaster(id)).hasSize(1)
|
||||||
|
source.setCompletedInstance(id, at, completed = false)
|
||||||
|
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anEditedCopyStillStandsForItsOccurrence() {
|
||||||
|
val at = now
|
||||||
|
val id = source.insertTask(form(title = "Weekly"))
|
||||||
|
makeRecurring(id, at)
|
||||||
|
source.setCompletedInstance(id, at, completed = true)
|
||||||
|
val copy = db.tasks().allIn(listId).single { it.id != id }
|
||||||
|
|
||||||
|
db.tasks().update(copy.copy(title = "Watered"))
|
||||||
|
|
||||||
|
assertThat(source.task(id, at)!!.taskId).isEqualTo(copy.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aDetachedOccurrenceWithALifeOfItsOwnStaysOut() {
|
||||||
|
val at = now
|
||||||
|
val id = source.insertTask(form(title = "Weekly"))
|
||||||
|
makeRecurring(id, at)
|
||||||
|
val master = db.tasks().entity(id)!!
|
||||||
|
val earlier = db.tasks().insert(master.copy(id = 0, masterId = id, recurrenceId = at, rrule = null, status = TaskStatus.COMPLETED))
|
||||||
|
source.setCompletedInstance(id, at + 7.days, completed = true)
|
||||||
|
source.insertTask(form(title = "Follow-up").copy(parentId = earlier))
|
||||||
|
|
||||||
|
source.setCompletedInstance(id, at + 7.days, completed = false)
|
||||||
|
|
||||||
|
assertThat(db.tasks().entity(earlier)!!.masterId).isNull()
|
||||||
|
assertThat(source.tasks(TaskQuery(listId = listId)).map { it.occurrenceStart?.epochSeconds })
|
||||||
|
.doesNotContain(at.epochSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reopeningAnotherOverrideOfAFinishedSeriesLeavesItsLastOccurrenceClosed() {
|
||||||
|
val at = now
|
||||||
|
val id = source.insertTask(form(title = "Three"))
|
||||||
|
makeRecurring(id, at, rule = "FREQ=WEEKLY;COUNT=3")
|
||||||
|
val master = db.tasks().entity(id)!!
|
||||||
|
val last = db.tasks().insert(master.copy(id = 0, masterId = id, recurrenceId = at + 14.days, rrule = null, status = TaskStatus.COMPLETED))
|
||||||
|
source.setCompletedInstance(id, at, completed = true)
|
||||||
|
source.setCompletedInstance(id, at + 7.days, completed = true)
|
||||||
|
assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.COMPLETED)
|
||||||
|
|
||||||
|
source.setCompletedInstance(last, at + 14.days, completed = false)
|
||||||
|
|
||||||
|
assertThat(db.tasks().entity(last)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||||
|
assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.COMPLETED)
|
||||||
|
assertThat(source.task(id, at + 7.days)!!.isClosed).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reopeningTheCopyWithNewRemindersKeepsThem() {
|
||||||
|
val at = now
|
||||||
|
val id = source.insertTask(form(title = "Weekly"))
|
||||||
|
makeRecurring(id, at)
|
||||||
|
source.setCompletedInstance(id, at, completed = true)
|
||||||
|
val copy = db.tasks().allIn(listId).single { it.id != id }
|
||||||
|
|
||||||
|
source.updateTask(
|
||||||
|
copy.id,
|
||||||
|
TaskForm(title = copy.title.orEmpty(), listId = listId, start = copy.dtstart, due = copy.due, percentComplete = 0),
|
||||||
|
reminders = listOf(TaskReminder(minutesBefore = 30)),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 7.days)
|
||||||
|
assertThat(db.tasks().entity(copy.id)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||||
|
assertThat(db.alarms().forTask(copy.id)).hasSize(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aDetachedOccurrenceReopenedSinceStaysOut() {
|
||||||
|
val at = now
|
||||||
|
val id = source.insertTask(form(title = "Weekly"))
|
||||||
|
makeRecurring(id, at)
|
||||||
|
val master = db.tasks().entity(id)!!
|
||||||
|
val earlier = db.tasks().insert(master.copy(id = 0, masterId = id, recurrenceId = at, rrule = null, status = TaskStatus.COMPLETED))
|
||||||
|
source.setCompletedInstance(id, at + 7.days, completed = true)
|
||||||
|
source.setCompleted(earlier, completed = false)
|
||||||
|
|
||||||
|
source.setCompletedInstance(id, at + 7.days, completed = false)
|
||||||
|
|
||||||
|
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at)
|
||||||
|
assertThat(db.tasks().entity(earlier)!!.masterId).isNull()
|
||||||
|
assertThat(source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id }.map { it.occurrenceStart?.epochSeconds })
|
||||||
|
.doesNotContain(at.epochSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anOccurrenceWhoseCopyIsGoneReadsAsNothing() {
|
||||||
|
val at = now
|
||||||
|
val id = source.insertTask(form(title = "Weekly"))
|
||||||
|
makeRecurring(id, at)
|
||||||
|
source.setCompletedInstance(id, at, completed = true)
|
||||||
|
source.deleteTask(db.tasks().allIn(listId).single { it.id != id }.id)
|
||||||
|
|
||||||
|
assertThat(source.task(id, at)).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun catchingUpOnAnOverdueSeriesKeepsEachTickUndoable() {
|
||||||
|
val at = now - 56.days
|
||||||
|
val id = source.insertTask(form(title = "Weekly"))
|
||||||
|
makeRecurring(id, at)
|
||||||
|
source.setCompletedInstance(id, at, completed = true)
|
||||||
|
val first = db.tasks().allIn(listId).single { it.id != id }
|
||||||
|
source.setCompletedInstance(id, at + 7.days, completed = true)
|
||||||
|
|
||||||
|
assertThat(db.advancedOccurrences().forCopy(first.id)).isNotNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aRuleTheServerReorderedStillMovesBack() {
|
||||||
|
val at = now
|
||||||
|
val id = source.insertTask(form(title = "Ten"))
|
||||||
|
makeRecurring(id, at, rule = "FREQ=WEEKLY;COUNT=10")
|
||||||
|
source.setCompletedInstance(id, at, completed = true)
|
||||||
|
db.tasks().update(db.tasks().entity(id)!!.copy(rrule = "count=9;FREQ=WEEKLY"))
|
||||||
|
|
||||||
|
source.setCompletedInstance(id, at, completed = false)
|
||||||
|
|
||||||
|
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reopeningWithProgressKeepsIt() {
|
||||||
|
val at = now
|
||||||
|
val id = source.insertTask(form(title = "Weekly"))
|
||||||
|
makeRecurring(id, at)
|
||||||
|
source.setCompletedInstance(id, at, completed = true)
|
||||||
|
val copy = db.tasks().allIn(listId).single { it.id != id }
|
||||||
|
|
||||||
|
source.updateTask(
|
||||||
|
copy.id,
|
||||||
|
TaskForm(title = copy.title.orEmpty(), listId = listId, start = copy.dtstart, due = copy.due, percentComplete = 40),
|
||||||
|
reminders = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at)
|
||||||
|
assertThat(db.tasks().override(id, at)!!.percentComplete).isEqualTo(40)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theEditorsTidyingDoesNotCountAsAnEdit() {
|
||||||
|
val at = now
|
||||||
|
val id = source.insertTask(form(title = "Weekly"))
|
||||||
|
makeRecurring(id, at)
|
||||||
|
db.tasks().update(db.tasks().entity(id)!!.copy(description = "From the server\n"))
|
||||||
|
source.setCompletedInstance(id, at, completed = true)
|
||||||
|
val copy = db.tasks().allIn(listId).single { it.id != id }
|
||||||
|
|
||||||
|
source.updateTask(
|
||||||
|
copy.id,
|
||||||
|
TaskForm(title = copy.title.orEmpty(), listId = listId, description = copy.description, start = copy.dtstart, due = copy.due, percentComplete = 0),
|
||||||
|
reminders = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun completingANonRecurringTaskThroughTheInstancePathWritesTheRowItself() {
|
fun completingANonRecurringTaskThroughTheInstancePathWritesTheRowItself() {
|
||||||
val id = source.insertTask(form(title = "Buy milk", due = now + 1.days))
|
val id = source.insertTask(form(title = "Buy milk", due = now + 1.days))
|
||||||
@@ -657,10 +1275,12 @@ class RoomTasksDataSourceTest {
|
|||||||
val outOfWindow = db.tasks().insert(
|
val outOfWindow = db.tasks().insert(
|
||||||
TaskEntity(listId = listId, uid = "old", title = "old", dtstart = longAgo, rrule = "FREQ=DAILY;COUNT=2", parentId = parent),
|
TaskEntity(listId = listId, uid = "old", title = "old", dtstart = longAgo, rrule = "FREQ=DAILY;COUNT=2", parentId = parent),
|
||||||
)
|
)
|
||||||
|
val at = now
|
||||||
val series = db.tasks().insert(
|
val series = db.tasks().insert(
|
||||||
TaskEntity(listId = listId, uid = "s", title = "s", dtstart = now, rrule = "FREQ=DAILY;COUNT=3", parentId = parent),
|
TaskEntity(listId = listId, uid = "s", title = "s", dtstart = at, rrule = "FREQ=DAILY;COUNT=3", parentId = parent),
|
||||||
)
|
)
|
||||||
source.setCompletedInstance(series, now, true)
|
// Not the first occurrence, so it forks an override rather than a copy.
|
||||||
|
source.setCompletedInstance(series, at + 1.days, true)
|
||||||
val plain = source.insertTask(form("plain").copy(parentId = parent))
|
val plain = source.insertTask(form("plain").copy(parentId = parent))
|
||||||
|
|
||||||
assertThat(source.subtaskIds(parent)).containsExactly(outOfWindow, series, plain)
|
assertThat(source.subtaskIds(parent)).containsExactly(outOfWindow, series, plain)
|
||||||
|
|||||||
+27
@@ -147,6 +147,33 @@ class TasksDatabaseMigrationTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun addsAnEmptyAccountLabel() {
|
||||||
|
helper.createDatabase(TEST_DB, 3).use { db ->
|
||||||
|
db.execSQL("INSERT INTO accounts (id, display_name) VALUES (1, 'me@example.org')")
|
||||||
|
}
|
||||||
|
|
||||||
|
helper.runMigrationsAndValidate(TEST_DB, 4, true, TasksDatabase.MIGRATION_3_4).use { db ->
|
||||||
|
db.query("SELECT display_name, label FROM accounts").use { c ->
|
||||||
|
assertThat(c.moveToFirst()).isTrue()
|
||||||
|
assertThat(c.getString(0)).isEqualTo("me@example.org")
|
||||||
|
assertThat(c.isNull(1)).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun addsTheAdvancedOccurrencesTable() {
|
||||||
|
helper.createDatabase(TEST_DB, 4).close()
|
||||||
|
|
||||||
|
helper.runMigrationsAndValidate(TEST_DB, 5, true, TasksDatabase.MIGRATION_4_5).use { db ->
|
||||||
|
db.query("SELECT COUNT(*) FROM advanced_occurrences").use { c ->
|
||||||
|
assertThat(c.moveToFirst()).isTrue()
|
||||||
|
assertThat(c.getInt(0)).isEqualTo(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val TEST_DB = "migration-test.db"
|
const val TEST_DB = "migration-test.db"
|
||||||
}
|
}
|
||||||
|
|||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.sync
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/** A server version replacing a moved series takes the moves' copies with it (#137). */
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class RoomSyncStoreAdvancesTest {
|
||||||
|
|
||||||
|
private val db = Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext<Context>(), TasksDatabase::class.java)
|
||||||
|
.allowMainThreadQueries().build()
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() = db.close()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun droppingAdvancesDeletesTheirCopies() {
|
||||||
|
val list = db.taskLists().insert(TaskListEntity(name = "Home", color = 0))
|
||||||
|
val at = Instant.fromEpochSeconds(Clock.System.now().epochSeconds)
|
||||||
|
val series = db.tasks().insert(TaskEntity(listId = list, uid = "s", title = "Weekly", dtstart = at, rrule = "FREQ=WEEKLY"))
|
||||||
|
RoomTasksDataSource(db).setCompletedInstance(series, at, true)
|
||||||
|
val copy = db.tasks().allIn(list).single { it.id != series }
|
||||||
|
db.tasks().update(copy.copy(href = "copy.ics"))
|
||||||
|
|
||||||
|
RoomSyncStore(db).dropAdvances(series)
|
||||||
|
|
||||||
|
assertThat(db.advancedOccurrences().forMaster(series)).isEmpty()
|
||||||
|
assertThat(db.tasks().entity(copy.id)!!.isDeleted).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun movesTheServerAlreadyHasStay() {
|
||||||
|
val list = db.taskLists().insert(TaskListEntity(name = "Home", color = 0))
|
||||||
|
val at = Instant.fromEpochSeconds(Clock.System.now().epochSeconds)
|
||||||
|
val series = db.tasks().insert(TaskEntity(listId = list, uid = "s", title = "Weekly", dtstart = at, rrule = "FREQ=WEEKLY"))
|
||||||
|
RoomTasksDataSource(db).setCompletedInstance(series, at, true)
|
||||||
|
val copy = db.tasks().allIn(list).single { it.id != series }
|
||||||
|
val store = RoomSyncStore(db)
|
||||||
|
store.markSynced(listOf(db.tasks().entity(series)!!), "s.ics", "1")
|
||||||
|
|
||||||
|
store.dropAdvances(series)
|
||||||
|
|
||||||
|
assertThat(db.tasks().entity(copy.id)!!.isDeleted).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aCopyGivenASubtaskSurvivesTheServerWinning() {
|
||||||
|
val list = db.taskLists().insert(TaskListEntity(name = "Home", color = 0))
|
||||||
|
val at = Instant.fromEpochSeconds(Clock.System.now().epochSeconds)
|
||||||
|
val series = db.tasks().insert(TaskEntity(listId = list, uid = "s", title = "Weekly", dtstart = at, rrule = "FREQ=WEEKLY"))
|
||||||
|
RoomTasksDataSource(db).setCompletedInstance(series, at, true)
|
||||||
|
val copy = db.tasks().allIn(list).single { it.id != series }
|
||||||
|
db.tasks().insert(TaskEntity(listId = list, uid = "p", title = "Part", parentId = copy.id))
|
||||||
|
|
||||||
|
RoomSyncStore(db).dropAdvances(series)
|
||||||
|
|
||||||
|
assertThat(db.tasks().entity(copy.id)).isNotNull()
|
||||||
|
assertThat(db.tasks().entity(copy.id)!!.isDeleted).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aTickDuringTheUploadIsNotSettled() {
|
||||||
|
val list = db.taskLists().insert(TaskListEntity(name = "Home", color = 0))
|
||||||
|
val at = Instant.fromEpochSeconds(Clock.System.now().epochSeconds)
|
||||||
|
val series = db.tasks().insert(
|
||||||
|
TaskEntity(listId = list, uid = "s", title = "Weekly", dtstart = at, rrule = "FREQ=WEEKLY", lastModified = at - 1.seconds),
|
||||||
|
)
|
||||||
|
val uploaded = db.tasks().entity(series)!!
|
||||||
|
RoomTasksDataSource(db).setCompletedInstance(series, at, true)
|
||||||
|
val store = RoomSyncStore(db)
|
||||||
|
|
||||||
|
store.markSynced(listOf(uploaded), "s.ics", "1")
|
||||||
|
|
||||||
|
assertThat(db.tasks().entity(series)!!.isDirty).isTrue()
|
||||||
|
assertThat(db.advancedOccurrences().forMaster(series).single().settled).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,10 +13,13 @@ import dagger.hilt.components.SingletonComponent
|
|||||||
import dagger.multibindings.IntoSet
|
import dagger.multibindings.IntoSet
|
||||||
import de.jeanlucmakiola.agendula.data.sync.AccountCreator
|
import de.jeanlucmakiola.agendula.data.sync.AccountCreator
|
||||||
import de.jeanlucmakiola.agendula.data.sync.AccountRepository
|
import de.jeanlucmakiola.agendula.data.sync.AccountRepository
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.AccountSyncRequests
|
||||||
import de.jeanlucmakiola.agendula.data.sync.CalDavGateway
|
import de.jeanlucmakiola.agendula.data.sync.CalDavGateway
|
||||||
import de.jeanlucmakiola.agendula.data.sync.LoginFlowRecord
|
import de.jeanlucmakiola.agendula.data.sync.LoginFlowRecord
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.ManualSync
|
||||||
import de.jeanlucmakiola.agendula.data.sync.OkHttpCalDavGateway
|
import de.jeanlucmakiola.agendula.data.sync.OkHttpCalDavGateway
|
||||||
import de.jeanlucmakiola.agendula.data.sync.PendingLoginFlowStore
|
import de.jeanlucmakiola.agendula.data.sync.PendingLoginFlowStore
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.PullSync
|
||||||
import de.jeanlucmakiola.agendula.data.sync.RemoteListRepository
|
import de.jeanlucmakiola.agendula.data.sync.RemoteListRepository
|
||||||
import de.jeanlucmakiola.agendula.data.sync.RemoteLists
|
import de.jeanlucmakiola.agendula.data.sync.RemoteLists
|
||||||
import de.jeanlucmakiola.agendula.data.sync.SyncNoticeNotifier
|
import de.jeanlucmakiola.agendula.data.sync.SyncNoticeNotifier
|
||||||
@@ -79,7 +82,11 @@ abstract class SyncBindModule {
|
|||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
@Singleton
|
@Singleton
|
||||||
abstract fun bindSyncRequests(impl: SyncTrigger): SyncRequests
|
abstract fun bindSyncRequests(impl: AccountSyncRequests): SyncRequests
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindManualSync(impl: PullSync): ManualSync
|
||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
@IntoSet
|
@IntoSet
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
|
|||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||||
import de.jeanlucmakiola.caldav.CalDavDiscovery
|
import de.jeanlucmakiola.caldav.CalDavDiscovery
|
||||||
|
import de.jeanlucmakiola.caldav.ServerTrust
|
||||||
import de.jeanlucmakiola.caldav.TaskCollection
|
import de.jeanlucmakiola.caldav.TaskCollection
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -16,6 +17,7 @@ import kotlinx.coroutines.flow.first
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
import java.io.IOException
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -35,7 +37,12 @@ interface AccountCreator {
|
|||||||
selected: Set<TaskCollection>,
|
selected: Set<TaskCollection>,
|
||||||
/** An existing account to sign in again, instead of creating one. */
|
/** An existing account to sign in again, instead of creating one. */
|
||||||
reauthenticating: Long? = null,
|
reauthenticating: Long? = null,
|
||||||
|
/** What the user agreed to trust on the way here; saved with the account. */
|
||||||
|
trust: ServerTrust = ServerTrust.DEFAULT,
|
||||||
): AccountRepository.Outcome
|
): AccountRepository.Outcome
|
||||||
|
|
||||||
|
/** What [accountId] was set up trusting, so signing in again starts from it. */
|
||||||
|
suspend fun trustFor(accountId: Long): ServerTrust
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,6 +66,7 @@ class AccountRepository @Inject constructor(
|
|||||||
private val quarantine: QuarantineStore,
|
private val quarantine: QuarantineStore,
|
||||||
private val notices: SyncNoticeStore,
|
private val notices: SyncNoticeStore,
|
||||||
private val collectionSupport: CollectionSupportStore,
|
private val collectionSupport: CollectionSupportStore,
|
||||||
|
private val serverTrust: ServerTrustStore,
|
||||||
private val gateway: CalDavGateway,
|
private val gateway: CalDavGateway,
|
||||||
private val availability: SyncAvailability,
|
private val availability: SyncAvailability,
|
||||||
private val settings: SettingsPrefs,
|
private val settings: SettingsPrefs,
|
||||||
@@ -97,6 +105,11 @@ class AccountRepository @Inject constructor(
|
|||||||
/** The accounts, observed, so a sync landing updates a screen that is open. */
|
/** The accounts, observed, so a sync landing updates a screen that is open. */
|
||||||
fun observeAll(): Flow<List<AccountEntity>> = database.accounts().observeAll()
|
fun observeAll(): Flow<List<AccountEntity>> = database.accounts().observeAll()
|
||||||
|
|
||||||
|
/** Names the account on screen; blank goes back to the default (#105). */
|
||||||
|
suspend fun setLabel(accountId: Long, label: String) = withContext(io) {
|
||||||
|
database.accounts().setLabel(accountId, label.trim().ifEmpty { null })
|
||||||
|
}
|
||||||
|
|
||||||
/** Every account's synced lists. */
|
/** Every account's synced lists. */
|
||||||
fun observeSyncedLists(): Flow<List<TaskListEntity>> = database.taskLists().observeSynced()
|
fun observeSyncedLists(): Flow<List<TaskListEntity>> = database.taskLists().observeSynced()
|
||||||
|
|
||||||
@@ -115,6 +128,7 @@ class AccountRepository @Inject constructor(
|
|||||||
found: CalDavDiscovery.Outcome.Found,
|
found: CalDavDiscovery.Outcome.Found,
|
||||||
selected: Set<TaskCollection>,
|
selected: Set<TaskCollection>,
|
||||||
reauthenticating: Long?,
|
reauthenticating: Long?,
|
||||||
|
trust: ServerTrust,
|
||||||
): Outcome = withContext(io) {
|
): Outcome = withContext(io) {
|
||||||
if (!availability.accountsUsable()) return@withContext Outcome.ExternalStorage
|
if (!availability.accountsUsable()) return@withContext Outcome.ExternalStorage
|
||||||
// "Sign in again" names its account, so a login name the server spells
|
// "Sign in again" names its account, so a login name the server spells
|
||||||
@@ -122,7 +136,7 @@ class AccountRepository @Inject constructor(
|
|||||||
// Only on the same server: anything else is a different account.
|
// Only on the same server: anything else is a different account.
|
||||||
val target = reauthenticating?.let { database.accounts().account(it) }
|
val target = reauthenticating?.let { database.accounts().account(it) }
|
||||||
if (target != null && sameServer(target, found)) {
|
if (target != null && sameServer(target, found)) {
|
||||||
return@withContext reauthenticate(target, target.displayName, username, appPassword, found, selected)
|
return@withContext reauthenticate(target, target.displayName, username, appPassword, found, selected, trust)
|
||||||
}
|
}
|
||||||
// Both stores, not just one. There is no unique index on
|
// Both stores, not just one. There is no unique index on
|
||||||
// accounts.display_name and nothing prunes Room when the system account
|
// accounts.display_name and nothing prunes Room when the system account
|
||||||
@@ -139,7 +153,7 @@ class AccountRepository @Inject constructor(
|
|||||||
// same name is still a duplicate, so this can never silently overwrite a
|
// same name is still a duplicate, so this can never silently overwrite a
|
||||||
// working credential.
|
// working credential.
|
||||||
if (existing != null && accountState.needsSignIn(existing.id)) {
|
if (existing != null && accountState.needsSignIn(existing.id)) {
|
||||||
return@withContext reauthenticate(existing, displayName, username, appPassword, found, selected)
|
return@withContext reauthenticate(existing, displayName, username, appPassword, found, selected, trust)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existing != null) return@withContext Outcome.AlreadyExists
|
if (existing != null) return@withContext Outcome.AlreadyExists
|
||||||
@@ -180,6 +194,15 @@ class AccountRepository @Inject constructor(
|
|||||||
inserted += attach(id, selected)
|
inserted += attach(id, selected)
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
|
// Inside the rollback's reach: a DataStore failure here would otherwise
|
||||||
|
// leave the row with no credential, the hazard the comment above names.
|
||||||
|
if (!saveTrust(accountId, trust)) {
|
||||||
|
rollback(accountId, inserted)
|
||||||
|
return@withContext Outcome.CredentialFailed(
|
||||||
|
Outcome.Cause.NOT_SAVED,
|
||||||
|
"the server trust could not be stored",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (!credentials.put(accountId, appPassword)) {
|
if (!credentials.put(accountId, appPassword)) {
|
||||||
// Never leave a half-made account behind: without a credential it
|
// Never leave a half-made account behind: without a credential it
|
||||||
@@ -207,6 +230,8 @@ class AccountRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun trustFor(accountId: Long): ServerTrust = serverTrust.get(accountId)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Points [selected] at [accountId], re-attaching what a previous removal
|
* Points [selected] at [accountId], re-attaching what a previous removal
|
||||||
* left behind rather than inserting a second copy.
|
* left behind rather than inserting a second copy.
|
||||||
@@ -298,6 +323,7 @@ class AccountRepository @Inject constructor(
|
|||||||
val outcome = gateway.discover(
|
val outcome = gateway.discover(
|
||||||
principal.toString(),
|
principal.toString(),
|
||||||
CalDavGateway.Credentials(username, password, principal),
|
CalDavGateway.Credentials(username, password, principal),
|
||||||
|
serverTrust.get(accountId),
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
is CalDavDiscovery.Outcome.Found -> Collections.Found(outcome.collections, attached)
|
is CalDavDiscovery.Outcome.Found -> Collections.Found(outcome.collections, attached)
|
||||||
@@ -306,6 +332,10 @@ class AccountRepository @Inject constructor(
|
|||||||
-> Collections.NeedsSignIn
|
-> Collections.NeedsSignIn
|
||||||
is CalDavDiscovery.Outcome.NotCalDav -> Collections.Failed(outcome.cause)
|
is CalDavDiscovery.Outcome.NotCalDav -> Collections.Failed(outcome.cause)
|
||||||
is CalDavDiscovery.Outcome.Failed -> Collections.Failed(outcome.cause)
|
is CalDavDiscovery.Outcome.Failed -> Collections.Failed(outcome.cause)
|
||||||
|
// A certificate that changed since it was trusted. The account offers
|
||||||
|
// "Sign in again" for a certificate failure, and that flow re-asks.
|
||||||
|
is CalDavDiscovery.Outcome.UntrustedCertificate ->
|
||||||
|
Collections.Failed(CalDavDiscovery.Outcome.Cause.TLS)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,6 +416,7 @@ class AccountRepository @Inject constructor(
|
|||||||
appPassword: String,
|
appPassword: String,
|
||||||
found: CalDavDiscovery.Outcome.Found,
|
found: CalDavDiscovery.Outcome.Found,
|
||||||
selected: Set<TaskCollection>,
|
selected: Set<TaskCollection>,
|
||||||
|
trust: ServerTrust,
|
||||||
): Outcome {
|
): Outcome {
|
||||||
val accountId = existing.id
|
val accountId = existing.id
|
||||||
if (!credentials.put(accountId, appPassword)) {
|
if (!credentials.put(accountId, appPassword)) {
|
||||||
@@ -394,6 +425,11 @@ class AccountRepository @Inject constructor(
|
|||||||
"the device keystore would not store the password",
|
"the device keystore would not store the password",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Before anything else changes, so a failure leaves the account stopped
|
||||||
|
// and the sign-in retryable rather than signed in with the old trust.
|
||||||
|
if (!withContext(NonCancellable) { saveTrust(accountId, trust) }) {
|
||||||
|
return Outcome.CredentialFailed(Outcome.Cause.NOT_SAVED, "the server trust could not be stored")
|
||||||
|
}
|
||||||
withContext(NonCancellable) {
|
withContext(NonCancellable) {
|
||||||
database.runInTransaction {
|
database.runInTransaction {
|
||||||
database.accounts().update(
|
database.accounts().update(
|
||||||
@@ -437,11 +473,23 @@ class AccountRepository @Inject constructor(
|
|||||||
* and holds the user's tasks; `SET NULL` returns it to being device-only,
|
* and holds the user's tasks; `SET NULL` returns it to being device-only,
|
||||||
* which is exactly where it came from.
|
* which is exactly where it came from.
|
||||||
*/
|
*/
|
||||||
private fun rollback(accountId: Long, inserted: List<Long>) = database.runInTransaction {
|
private suspend fun rollback(accountId: Long, inserted: List<Long>) {
|
||||||
inserted.forEach { database.taskLists().delete(it) }
|
database.runInTransaction {
|
||||||
database.accounts().delete(accountId)
|
inserted.forEach { database.taskLists().delete(it) }
|
||||||
|
database.accounts().delete(accountId)
|
||||||
|
}
|
||||||
|
// Ids are never reused, and a later create overwrites the keys anyway.
|
||||||
|
runCatching { serverTrust.forget(accountId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun saveTrust(accountId: Long, trust: ServerTrust): Boolean =
|
||||||
|
try {
|
||||||
|
serverTrust.put(accountId, trust)
|
||||||
|
true
|
||||||
|
} catch (_: IOException) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Puts every existing account back on the periodic schedule.
|
* Puts every existing account back on the periodic schedule.
|
||||||
*
|
*
|
||||||
@@ -530,6 +578,7 @@ class AccountRepository @Inject constructor(
|
|||||||
// The same reasoning, for what the server said it would let us
|
// The same reasoning, for what the server said it would let us
|
||||||
// create: keyed by an id nothing will ever mention again.
|
// create: keyed by an id nothing will ever mention again.
|
||||||
collectionSupport.forget(accountId)
|
collectionSupport.forget(accountId)
|
||||||
|
serverTrust.forget(accountId)
|
||||||
credentials.clear(accountId)
|
credentials.clear(accountId)
|
||||||
database.accounts().delete(accountId)
|
database.accounts().delete(accountId)
|
||||||
accounts.find(displayName)?.let { accounts.remove(it) }
|
accounts.find(displayName)?.let { accounts.remove(it) }
|
||||||
@@ -557,6 +606,7 @@ class AccountRepository @Inject constructor(
|
|||||||
try {
|
try {
|
||||||
gateway.revokeAppPassword(
|
gateway.revokeAppPassword(
|
||||||
CalDavGateway.Credentials(username, password, origin),
|
CalDavGateway.Credentials(username, password, origin),
|
||||||
|
serverTrust.get(accountId),
|
||||||
)
|
)
|
||||||
} catch (_: CancellationException) {
|
} catch (_: CancellationException) {
|
||||||
// Deliberately swallowed. If the caller went away mid-revoke we still
|
// Deliberately swallowed. If the caller went away mid-revoke we still
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.sync
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/** [SyncRequests] by account id; [SyncTrigger] keys on the account's stable display name. */
|
||||||
|
class AccountSyncRequests @Inject constructor(
|
||||||
|
private val database: TasksDatabase,
|
||||||
|
private val trigger: SyncTrigger,
|
||||||
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
|
) : SyncRequests {
|
||||||
|
|
||||||
|
override suspend fun syncNow(accountId: Long) {
|
||||||
|
val name = withContext(io) { database.accounts().account(accountId)?.displayName } ?: return
|
||||||
|
trigger.enqueue(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import de.jeanlucmakiola.caldav.AppPassword
|
|||||||
import de.jeanlucmakiola.caldav.CalDavDiscovery
|
import de.jeanlucmakiola.caldav.CalDavDiscovery
|
||||||
import de.jeanlucmakiola.caldav.CalDavHttp
|
import de.jeanlucmakiola.caldav.CalDavHttp
|
||||||
import de.jeanlucmakiola.caldav.NextcloudLoginFlow
|
import de.jeanlucmakiola.caldav.NextcloudLoginFlow
|
||||||
|
import de.jeanlucmakiola.caldav.ServerTrust
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.job
|
import kotlinx.coroutines.job
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -25,12 +26,22 @@ import javax.inject.Singleton
|
|||||||
interface CalDavGateway {
|
interface CalDavGateway {
|
||||||
|
|
||||||
/** Discovery against [target], optionally carrying credentials. */
|
/** Discovery against [target], optionally carrying credentials. */
|
||||||
suspend fun discover(target: String, credentials: Credentials? = null): CalDavDiscovery.Outcome
|
suspend fun discover(
|
||||||
|
target: String,
|
||||||
|
credentials: Credentials? = null,
|
||||||
|
trust: ServerTrust = ServerTrust.DEFAULT,
|
||||||
|
): CalDavDiscovery.Outcome
|
||||||
|
|
||||||
/** Starts Nextcloud Login Flow v2, or returns null if this is not a Nextcloud. */
|
/** Starts Nextcloud Login Flow v2, or returns null if this is not a Nextcloud. */
|
||||||
suspend fun startLoginFlow(server: HttpUrl): NextcloudLoginFlow.Flow?
|
suspend fun startLoginFlow(
|
||||||
|
server: HttpUrl,
|
||||||
|
trust: ServerTrust = ServerTrust.DEFAULT,
|
||||||
|
): NextcloudLoginFlow.Flow?
|
||||||
|
|
||||||
suspend fun pollLoginFlow(flow: NextcloudLoginFlow.Flow): NextcloudLoginFlow.PollResult
|
suspend fun pollLoginFlow(
|
||||||
|
flow: NextcloudLoginFlow.Flow,
|
||||||
|
trust: ServerTrust = ServerTrust.DEFAULT,
|
||||||
|
): NextcloudLoginFlow.PollResult
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hands an app password back to the server, best effort.
|
* Hands an app password back to the server, best effort.
|
||||||
@@ -38,7 +49,10 @@ interface CalDavGateway {
|
|||||||
* Without it, uninstalling never revokes anything — the credential we minted
|
* Without it, uninstalling never revokes anything — the credential we minted
|
||||||
* outlives the app in the user's device list.
|
* outlives the app in the user's device list.
|
||||||
*/
|
*/
|
||||||
suspend fun revokeAppPassword(credentials: Credentials): Boolean
|
suspend fun revokeAppPassword(
|
||||||
|
credentials: Credentials,
|
||||||
|
trust: ServerTrust = ServerTrust.DEFAULT,
|
||||||
|
): Boolean
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The same, for a password the login flow just minted.
|
* The same, for a password the login flow just minted.
|
||||||
@@ -49,7 +63,10 @@ interface CalDavGateway {
|
|||||||
* principal, and a subpath install's `https://host/nextcloud/` would collapse
|
* principal, and a subpath install's `https://host/nextcloud/` would collapse
|
||||||
* to `https://host/` — a DELETE that 404s on every one of them.
|
* to `https://host/` — a DELETE that 404s on every one of them.
|
||||||
*/
|
*/
|
||||||
suspend fun revokeIssuedAppPassword(credentials: Credentials): Boolean
|
suspend fun revokeIssuedAppPassword(
|
||||||
|
credentials: Credentials,
|
||||||
|
trust: ServerTrust = ServerTrust.DEFAULT,
|
||||||
|
): Boolean
|
||||||
|
|
||||||
/** Credentials, and the origin whose registrable domain they are scoped to. */
|
/** Credentials, and the origin whose registrable domain they are scoped to. */
|
||||||
data class Credentials(val username: String, val password: String, val origin: HttpUrl)
|
data class Credentials(val username: String, val password: String, val origin: HttpUrl)
|
||||||
@@ -71,17 +88,19 @@ class OkHttpCalDavGateway @Inject constructor(
|
|||||||
override suspend fun discover(
|
override suspend fun discover(
|
||||||
target: String,
|
target: String,
|
||||||
credentials: CalDavGateway.Credentials?,
|
credentials: CalDavGateway.Credentials?,
|
||||||
|
trust: ServerTrust,
|
||||||
): CalDavDiscovery.Outcome = withContext(io) {
|
): CalDavDiscovery.Outcome = withContext(io) {
|
||||||
val client = credentials?.let {
|
val client = credentials?.let {
|
||||||
CalDavHttp.authenticated(userAgent, it.username, it.password, it.origin)
|
CalDavHttp.authenticated(userAgent, it.username, it.password, it.origin, trust)
|
||||||
} ?: CalDavHttp.anonymous(userAgent)
|
} ?: CalDavHttp.anonymous(userAgent, trust)
|
||||||
val job = coroutineContext.job
|
val job = coroutineContext.job
|
||||||
CalDavDiscovery(client, SystemDnsResolver { !job.isActive }).discover(target)
|
CalDavDiscovery(client, SystemDnsResolver { !job.isActive }, allowCleartext = trust.allowCleartext)
|
||||||
|
.discover(target)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun startLoginFlow(server: HttpUrl): NextcloudLoginFlow.Flow? =
|
override suspend fun startLoginFlow(server: HttpUrl, trust: ServerTrust): NextcloudLoginFlow.Flow? =
|
||||||
withContext(io) {
|
withContext(io) {
|
||||||
NextcloudLoginFlow(CalDavHttp.anonymous(userAgent), userAgent)
|
NextcloudLoginFlow(CalDavHttp.anonymous(userAgent, trust), userAgent)
|
||||||
.start(server, now())
|
.start(server, now())
|
||||||
.getOrNull()
|
.getOrNull()
|
||||||
}
|
}
|
||||||
@@ -93,9 +112,12 @@ class OkHttpCalDavGateway @Inject constructor(
|
|||||||
* the shared client's ceiling is sized for a multiget, not for a two-second
|
* the shared client's ceiling is sized for a multiget, not for a two-second
|
||||||
* poll loop against a server that answered a moment ago.
|
* poll loop against a server that answered a moment ago.
|
||||||
*/
|
*/
|
||||||
override suspend fun pollLoginFlow(flow: NextcloudLoginFlow.Flow): NextcloudLoginFlow.PollResult =
|
override suspend fun pollLoginFlow(
|
||||||
|
flow: NextcloudLoginFlow.Flow,
|
||||||
|
trust: ServerTrust,
|
||||||
|
): NextcloudLoginFlow.PollResult =
|
||||||
withContext(io) {
|
withContext(io) {
|
||||||
val client = CalDavHttp.anonymous(userAgent).newBuilder()
|
val client = CalDavHttp.anonymous(userAgent, trust).newBuilder()
|
||||||
.callTimeout(POLL_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
.callTimeout(POLL_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||||
.build()
|
.build()
|
||||||
NextcloudLoginFlow(client, userAgent).poll(flow, now())
|
NextcloudLoginFlow(client, userAgent).poll(flow, now())
|
||||||
@@ -103,18 +125,20 @@ class OkHttpCalDavGateway @Inject constructor(
|
|||||||
|
|
||||||
override suspend fun revokeAppPassword(
|
override suspend fun revokeAppPassword(
|
||||||
credentials: CalDavGateway.Credentials,
|
credentials: CalDavGateway.Credentials,
|
||||||
|
trust: ServerTrust,
|
||||||
): Boolean = withContext(io) {
|
): Boolean = withContext(io) {
|
||||||
val client = CalDavHttp.authenticated(
|
val client = CalDavHttp.authenticated(
|
||||||
userAgent, credentials.username, credentials.password, credentials.origin,
|
userAgent, credentials.username, credentials.password, credentials.origin, trust,
|
||||||
)
|
)
|
||||||
AppPassword.revoke(client, credentials.origin)
|
AppPassword.revoke(client, credentials.origin)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun revokeIssuedAppPassword(
|
override suspend fun revokeIssuedAppPassword(
|
||||||
credentials: CalDavGateway.Credentials,
|
credentials: CalDavGateway.Credentials,
|
||||||
|
trust: ServerTrust,
|
||||||
): Boolean = withContext(io) {
|
): Boolean = withContext(io) {
|
||||||
val client = CalDavHttp.authenticated(
|
val client = CalDavHttp.authenticated(
|
||||||
userAgent, credentials.username, credentials.password, credentials.origin,
|
userAgent, credentials.username, credentials.password, credentials.origin, trust,
|
||||||
)
|
)
|
||||||
AppPassword.revokeAt(client, credentials.origin)
|
AppPassword.revokeAt(client, credentials.origin)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -609,7 +609,7 @@ class CollectionSyncer(
|
|||||||
// via `master_id`.
|
// via `master_id`.
|
||||||
val (buried, kept) = local.rows.partition { it.isDeleted && it.recurrenceId != null }
|
val (buried, kept) = local.rows.partition { it.isDeleted && it.recurrenceId != null }
|
||||||
if (buried.isNotEmpty()) store.deleteAll(buried.map { it.id })
|
if (buried.isNotEmpty()) store.deleteAll(buried.map { it.id })
|
||||||
store.markSynced(kept.map { it.id }, key, eTag)
|
store.markSynced(kept, key, eTag)
|
||||||
// Both, because a resource that failed as a create was counted under
|
// Both, because a resource that failed as a create was counted under
|
||||||
// its UID and is now counted under its href. Clearing one would leak
|
// its UID and is now counted under its href. Clearing one would leak
|
||||||
// the other into the store forever.
|
// the other into the store forever.
|
||||||
@@ -825,6 +825,7 @@ class CollectionSyncer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pendingDiscard.remove(href)?.let { cause ->
|
pendingDiscard.remove(href)?.let { cause ->
|
||||||
|
store.dropAdvances(masterId)
|
||||||
report = report.copy(
|
report = report.copy(
|
||||||
discardedEdits = report.discardedEdits +
|
discardedEdits = report.discardedEdits +
|
||||||
DiscardedEdit(uid, losing?.title, cause),
|
DiscardedEdit(uid, losing?.title, cause),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import androidx.datastore.preferences.core.longPreferencesKey
|
|||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
|
import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
|
||||||
import de.jeanlucmakiola.caldav.NextcloudLoginFlow
|
import de.jeanlucmakiola.caldav.NextcloudLoginFlow
|
||||||
|
import de.jeanlucmakiola.caldav.ServerTrust
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
@@ -23,8 +24,8 @@ import javax.inject.Singleton
|
|||||||
*/
|
*/
|
||||||
interface LoginFlowRecord {
|
interface LoginFlowRecord {
|
||||||
|
|
||||||
/** Called **before** the browser is handed the URL. */
|
/** Called **before** the browser is handed the URL; [trust] is what reaching it took. */
|
||||||
suspend fun remember(flow: NextcloudLoginFlow.Flow)
|
suspend fun remember(flow: NextcloudLoginFlow.Flow, trust: ServerTrust = ServerTrust.DEFAULT)
|
||||||
|
|
||||||
/** The flow is over, however it ended. */
|
/** The flow is over, however it ended. */
|
||||||
suspend fun forget()
|
suspend fun forget()
|
||||||
@@ -58,12 +59,13 @@ class PendingLoginFlowStore @Inject constructor(
|
|||||||
private var reclaimed = false
|
private var reclaimed = false
|
||||||
|
|
||||||
/** Records [flow] so a process that dies mid-approval can still finish with it. */
|
/** Records [flow] so a process that dies mid-approval can still finish with it. */
|
||||||
override suspend fun remember(flow: NextcloudLoginFlow.Flow) {
|
override suspend fun remember(flow: NextcloudLoginFlow.Flow, trust: ServerTrust) {
|
||||||
dataStore.edit { prefs ->
|
dataStore.edit { prefs ->
|
||||||
prefs[LOGIN_URL] = flow.loginUrl.toString()
|
prefs[LOGIN_URL] = flow.loginUrl.toString()
|
||||||
prefs[POLL_ENDPOINT] = flow.pollEndpoint.toString()
|
prefs[POLL_ENDPOINT] = flow.pollEndpoint.toString()
|
||||||
prefs[POLL_TOKEN] = flow.pollToken
|
prefs[POLL_TOKEN] = flow.pollToken
|
||||||
prefs[DEADLINE] = flow.deadlineEpochSeconds
|
prefs[DEADLINE] = flow.deadlineEpochSeconds
|
||||||
|
TRUST.write(prefs, trust)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +76,7 @@ class PendingLoginFlowStore @Inject constructor(
|
|||||||
prefs.remove(POLL_ENDPOINT)
|
prefs.remove(POLL_ENDPOINT)
|
||||||
prefs.remove(POLL_TOKEN)
|
prefs.remove(POLL_TOKEN)
|
||||||
prefs.remove(DEADLINE)
|
prefs.remove(DEADLINE)
|
||||||
|
TRUST.remove(prefs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +101,8 @@ class PendingLoginFlowStore @Inject constructor(
|
|||||||
reclaimed = true
|
reclaimed = true
|
||||||
}
|
}
|
||||||
val flow = pending() ?: return
|
val flow = pending() ?: return
|
||||||
when (val result = gateway.pollLoginFlow(flow)) {
|
val trust = trust()
|
||||||
|
when (val result = gateway.pollLoginFlow(flow, trust)) {
|
||||||
is NextcloudLoginFlow.PollResult.Approved -> {
|
is NextcloudLoginFlow.PollResult.Approved -> {
|
||||||
// Cleared first: a revocation that fails must not leave a token
|
// Cleared first: a revocation that fails must not leave a token
|
||||||
// that would be polled again, and the 200 is already spent.
|
// that would be polled again, and the 200 is already spent.
|
||||||
@@ -109,6 +113,7 @@ class PendingLoginFlowStore @Inject constructor(
|
|||||||
password = result.credentials.appPassword,
|
password = result.credentials.appPassword,
|
||||||
origin = result.credentials.server,
|
origin = result.credentials.server,
|
||||||
),
|
),
|
||||||
|
trust,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,10 +141,13 @@ class PendingLoginFlowStore @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun trust(): ServerTrust = TRUST.read(dataStore.data.first())
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
val LOGIN_URL = stringPreferencesKey("login_flow_url")
|
val LOGIN_URL = stringPreferencesKey("login_flow_url")
|
||||||
val POLL_ENDPOINT = stringPreferencesKey("login_flow_poll_endpoint")
|
val POLL_ENDPOINT = stringPreferencesKey("login_flow_poll_endpoint")
|
||||||
val POLL_TOKEN = stringPreferencesKey("login_flow_poll_token")
|
val POLL_TOKEN = stringPreferencesKey("login_flow_poll_token")
|
||||||
val DEADLINE = longPreferencesKey("login_flow_deadline")
|
val DEADLINE = longPreferencesKey("login_flow_deadline")
|
||||||
|
val TRUST = ServerTrustKeys("login_flow_trusted_certificates", "login_flow_cleartext")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.sync
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.work.WorkInfo
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull to sync over [SyncTrigger]: an expedited sync per account, and
|
||||||
|
* [syncing] held until they finish.
|
||||||
|
*
|
||||||
|
* A sync waiting on its network constraint never starts, so if none is running
|
||||||
|
* after [START_GRACE] the indicator lets go; the work stays queued and runs
|
||||||
|
* once the device is back online.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class PullSync @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val accounts: AccountRepository,
|
||||||
|
private val trigger: SyncTrigger,
|
||||||
|
availability: SyncAvailability,
|
||||||
|
@ApplicationScope private val scope: CoroutineScope,
|
||||||
|
) : ManualSync {
|
||||||
|
|
||||||
|
override val available: Flow<Boolean> =
|
||||||
|
combine(availability.observe(), accounts.observeAll()) { usable, all -> usable && all.isNotEmpty() }
|
||||||
|
|
||||||
|
private val _syncing = MutableStateFlow(false)
|
||||||
|
override val syncing: StateFlow<Boolean> = _syncing
|
||||||
|
|
||||||
|
override fun syncAll() {
|
||||||
|
if (_syncing.value) return
|
||||||
|
_syncing.value = true
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val names = accounts.syncable().map { trigger.enqueue(it.displayName, expedited = true) }
|
||||||
|
if (names.isNotEmpty()) awaitSyncs(names)
|
||||||
|
} finally {
|
||||||
|
_syncing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun awaitSyncs(names: List<String>) {
|
||||||
|
val workManager = WorkManager.getInstance(context)
|
||||||
|
val states = combine(names.map { workManager.getWorkInfosForUniqueWorkFlow(it) }) { infos ->
|
||||||
|
infos.flatMap { it }.map { it.state }
|
||||||
|
}
|
||||||
|
withTimeoutOrNull(MAX_WAIT) {
|
||||||
|
val started = withTimeoutOrNull(START_GRACE) {
|
||||||
|
states.first { all -> WorkInfo.State.RUNNING in all || all.all { it.isFinished } }
|
||||||
|
} ?: return@withTimeoutOrNull
|
||||||
|
if (started.all { it.isFinished }) return@withTimeoutOrNull
|
||||||
|
states.map { all -> WorkInfo.State.RUNNING in all }.first { running -> !running }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val START_GRACE = 3.seconds
|
||||||
|
val MAX_WAIT = 2.minutes
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import de.jeanlucmakiola.caldav.CollectionOutcome
|
|||||||
import de.jeanlucmakiola.caldav.CollectionSupport
|
import de.jeanlucmakiola.caldav.CollectionSupport
|
||||||
import de.jeanlucmakiola.caldav.DavCollectionAdmin
|
import de.jeanlucmakiola.caldav.DavCollectionAdmin
|
||||||
import de.jeanlucmakiola.caldav.ResourceNames
|
import de.jeanlucmakiola.caldav.ResourceNames
|
||||||
|
import de.jeanlucmakiola.caldav.ServerTrust
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.NonCancellable
|
import kotlinx.coroutines.NonCancellable
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
@@ -53,6 +54,7 @@ class RemoteListRepository @Inject constructor(
|
|||||||
private val accountState: AccountStateStore,
|
private val accountState: AccountStateStore,
|
||||||
private val resolver: ProviderResolver,
|
private val resolver: ProviderResolver,
|
||||||
private val push: PushStore,
|
private val push: PushStore,
|
||||||
|
private val trust: ServerTrustStore,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : RemoteLists {
|
) : RemoteLists {
|
||||||
|
|
||||||
@@ -252,11 +254,11 @@ class RemoteListRepository @Inject constructor(
|
|||||||
val origin = account.principalUrl?.toHttpUrlOrNull() ?: return null
|
val origin = account.principalUrl?.toHttpUrlOrNull() ?: return null
|
||||||
val password = (credentials.get(account.id) as? CredentialStore.Secret.Present)?.value
|
val password = (credentials.get(account.id) as? CredentialStore.Secret.Present)?.value
|
||||||
?: return null
|
?: return null
|
||||||
return DavCollectionAdmin(client(username, password, origin))
|
return DavCollectionAdmin(client(username, password, origin, trust.get(account.id)))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun client(username: String, password: String, origin: HttpUrl): OkHttpClient =
|
private fun client(username: String, password: String, origin: HttpUrl, trust: ServerTrust): OkHttpClient =
|
||||||
CalDavHttp.authenticated(USER_AGENT, username, password, origin)
|
CalDavHttp.authenticated(USER_AGENT, username, password, origin, trust)
|
||||||
|
|
||||||
private suspend fun forgetPerListState(listId: Long) {
|
private suspend fun forgetPerListState(listId: Long) {
|
||||||
val ids = setOf(listId)
|
val ids = setOf(listId)
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.sync
|
||||||
|
|
||||||
|
import androidx.datastore.preferences.core.MutablePreferences
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import de.jeanlucmakiola.caldav.ServerTrust
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one mapping between a [ServerTrust] and the key-value stores it is kept
|
||||||
|
* in, so a field added to it cannot be dropped by one of them.
|
||||||
|
*/
|
||||||
|
class ServerTrustKeys(private val certificatesName: String, private val cleartextName: String) {
|
||||||
|
|
||||||
|
private val certificates = stringSetPreferencesKey(certificatesName)
|
||||||
|
private val cleartext = booleanPreferencesKey(cleartextName)
|
||||||
|
|
||||||
|
fun read(prefs: Preferences) = ServerTrust(
|
||||||
|
pinnedCertificates = prefs[certificates].orEmpty(),
|
||||||
|
allowCleartext = prefs[cleartext] ?: false,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun write(prefs: MutablePreferences, trust: ServerTrust) {
|
||||||
|
if (trust.pinnedCertificates.isEmpty()) {
|
||||||
|
prefs.remove(certificates)
|
||||||
|
} else {
|
||||||
|
prefs[certificates] = trust.pinnedCertificates
|
||||||
|
}
|
||||||
|
if (trust.allowCleartext) prefs[cleartext] = true else prefs.remove(cleartext)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun remove(prefs: MutablePreferences) = write(prefs, ServerTrust.DEFAULT)
|
||||||
|
|
||||||
|
fun read(handle: SavedStateHandle) = ServerTrust(
|
||||||
|
pinnedCertificates = handle.get<ArrayList<String>>(certificatesName).orEmpty().toSet(),
|
||||||
|
allowCleartext = handle[cleartextName] ?: false,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun write(handle: SavedStateHandle, trust: ServerTrust) {
|
||||||
|
handle[certificatesName] = ArrayList(trust.pinnedCertificates)
|
||||||
|
handle[cleartextName] = trust.allowCleartext
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.sync
|
||||||
|
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
|
||||||
|
import de.jeanlucmakiola.caldav.ServerTrust
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/** What each account's user agreed to trust: pinned certificates and plain http. */
|
||||||
|
@Singleton
|
||||||
|
class ServerTrustStore @Inject constructor(
|
||||||
|
@SyncStateDataStore private val dataStore: DataStore<Preferences>,
|
||||||
|
) {
|
||||||
|
|
||||||
|
suspend fun get(accountId: Long): ServerTrust = read(dataStore.data.first(), accountId)
|
||||||
|
|
||||||
|
fun observe(accountId: Long): Flow<ServerTrust> =
|
||||||
|
dataStore.data.map { read(it, accountId) }.distinctUntilChanged()
|
||||||
|
|
||||||
|
suspend fun put(accountId: Long, trust: ServerTrust) {
|
||||||
|
dataStore.edit { keys(accountId).write(it, trust) }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun forget(accountId: Long) {
|
||||||
|
dataStore.edit { keys(accountId).remove(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun read(prefs: Preferences, accountId: Long) = keys(accountId).read(prefs)
|
||||||
|
|
||||||
|
private fun keys(accountId: Long) =
|
||||||
|
ServerTrustKeys("server_trust_certs_$accountId", "server_trust_cleartext_$accountId")
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ class SyncEngine @Inject constructor(
|
|||||||
private val notices: SyncNoticeStore,
|
private val notices: SyncNoticeStore,
|
||||||
private val availability: SyncAvailability,
|
private val availability: SyncAvailability,
|
||||||
private val push: PushRegistrar,
|
private val push: PushRegistrar,
|
||||||
|
private val trust: ServerTrustStore,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@@ -90,7 +91,7 @@ class SyncEngine @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val client = CalDavHttp.authenticated(USER_AGENT, username, password, origin)
|
val client = CalDavHttp.authenticated(USER_AGENT, username, password, origin, trust.get(account.id))
|
||||||
val subscriptions = push.subscriptionsByHref(account.id)
|
val subscriptions = push.subscriptionsByHref(account.id)
|
||||||
val reports = syncCollections(account) { url ->
|
val reports = syncCollections(account) { url ->
|
||||||
CalendarCollection(client, url, pushRegistration = subscriptions[url.toString()])
|
CalendarCollection(client, url, pushRegistration = subscriptions[url.toString()])
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ data class SyncFailure(val kind: Kind, val httpCode: Int? = null) {
|
|||||||
private val TLS = listOf(
|
private val TLS = listOf(
|
||||||
"SSLHandshakeException",
|
"SSLHandshakeException",
|
||||||
"SSLPeerUnverifiedException",
|
"SSLPeerUnverifiedException",
|
||||||
|
"UntrustedHostException",
|
||||||
"CertPathValidatorException",
|
"CertPathValidatorException",
|
||||||
"CertificateException",
|
"CertificateException",
|
||||||
"SSLException",
|
"SSLException",
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ package de.jeanlucmakiola.agendula.data.sync
|
|||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.recurrenceAnchor
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.wholeSecond
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,12 +45,21 @@ interface SyncStore {
|
|||||||
fun deleteAll(taskIds: List<Long>)
|
fun deleteAll(taskIds: List<Long>)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Records href and ETag on a resource's rows, clearing `is_dirty`.
|
* Forgets how ticking occurrences moved [masterId]'s series, and deletes the
|
||||||
|
* copies that left: the server's version replaced the moved series, and its
|
||||||
|
* own overrides stand for those occurrences again. A row given a life of its
|
||||||
|
* own since stays.
|
||||||
|
*/
|
||||||
|
fun dropAdvances(masterId: Long) = Unit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records href and ETag on a resource's [rows], as they were serialised,
|
||||||
|
* clearing `is_dirty` on those that did not change since.
|
||||||
*
|
*
|
||||||
* The caller excludes any row it left out of the body — that row is deleted,
|
* The caller excludes any row it left out of the body — that row is deleted,
|
||||||
* not marked synced.
|
* not marked synced.
|
||||||
*/
|
*/
|
||||||
fun markSynced(taskIds: List<Long>, href: String?, eTag: String?)
|
fun markSynced(rows: List<TaskEntity>, href: String?, eTag: String?)
|
||||||
|
|
||||||
fun setParent(taskId: Long, parentId: Long?)
|
fun setParent(taskId: Long, parentId: Long?)
|
||||||
|
|
||||||
@@ -97,8 +109,42 @@ class RoomSyncStore @Inject constructor(
|
|||||||
if (taskIds.isNotEmpty()) database.tasks().deleteAll(taskIds)
|
if (taskIds.isNotEmpty()) database.tasks().deleteAll(taskIds)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun markSynced(taskIds: List<Long>, href: String?, eTag: String?) {
|
override fun dropAdvances(masterId: Long) = database.runInTransaction {
|
||||||
if (taskIds.isNotEmpty()) database.tasks().markSynced(taskIds, href, eTag)
|
val advanced = database.advancedOccurrences()
|
||||||
|
val tasks = database.tasks()
|
||||||
|
val master = tasks.entity(masterId)
|
||||||
|
// Only moves the server never saw, on copies that are still just that.
|
||||||
|
advanced.forMaster(masterId).filter { !it.settled && it.undoable }.forEach { record ->
|
||||||
|
// An edited detached row was unlinked already, so only untouched ones are named.
|
||||||
|
val rows = advanced.detachedFor(record.copyId).mapNotNull { it.taskId } + record.copyId
|
||||||
|
advanced.delete(record.copyId)
|
||||||
|
rows.mapNotNull { tasks.entity(it) }
|
||||||
|
.filter { row ->
|
||||||
|
!row.isDeleted && tasks.subtasks(row.id).isEmpty() &&
|
||||||
|
(master == null || row.listId == master.listId && row.parentId == master.parentId)
|
||||||
|
}
|
||||||
|
.forEach { row ->
|
||||||
|
// Never uploaded: nobody to tell. Uploaded: a tombstone the next run deletes.
|
||||||
|
if (row.href == null) tasks.delete(row.id)
|
||||||
|
else tasks.update(row.copy(isDeleted = true, isDirty = true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun markSynced(rows: List<TaskEntity>, href: String?, eTag: String?) {
|
||||||
|
if (rows.isEmpty()) return
|
||||||
|
database.runInTransaction {
|
||||||
|
database.tasks().markSynced(rows.map { it.id }, href, eTag)
|
||||||
|
// Changed while the body was on its way, by a tick or an edit: still owed to the server.
|
||||||
|
rows.forEach { database.tasks().dirtyIfChangedSince(it.id, it.lastModified) }
|
||||||
|
// The server now holds each series as far as the body moved it; a tick
|
||||||
|
// since is still the device's own.
|
||||||
|
rows.filter { it.recurrenceId == null }.forEach { row ->
|
||||||
|
val anchor = row.recurrenceAnchor
|
||||||
|
if (anchor == null) database.advancedOccurrences().settle(listOf(row.id))
|
||||||
|
else database.advancedOccurrences().settleUpTo(row.id, wholeSecond(anchor) + 1.seconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setParent(taskId: Long, parentId: Long?) {
|
override fun setParent(taskId: Long, parentId: Long?) {
|
||||||
|
|||||||
@@ -35,11 +35,7 @@ import kotlin.time.Duration.Companion.seconds
|
|||||||
@Singleton
|
@Singleton
|
||||||
class SyncTrigger @Inject constructor(
|
class SyncTrigger @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
) : SyncRequests {
|
) {
|
||||||
|
|
||||||
override fun syncNow(accountName: String) {
|
|
||||||
enqueue(accountName)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts a sync now.
|
* Starts a sync now.
|
||||||
|
|||||||
@@ -56,8 +56,8 @@ class SyncWorker @AssistedInject constructor(
|
|||||||
// log line is the same as saying nothing. Only what is new: the
|
// log line is the same as saying nothing. Only what is new: the
|
||||||
// store has already dropped whatever the user has been told.
|
// store has already dropped whatever the user has been told.
|
||||||
noticeNotifier.post(accountName, outcome.notices)
|
noticeNotifier.post(accountName, outcome.notices)
|
||||||
// Nothing else re-arms reminders for what a sync pulled in: in our
|
// Here rather than left to ReminderRefresher's debounce: once
|
||||||
// own store no provider broadcast fires.
|
// the worker returns, nothing keeps the process alive for it.
|
||||||
if (outcome.reports.any { it.hadWork || it.discardedEdits.isNotEmpty() }) {
|
if (outcome.reports.any { it.hadWork || it.discardedEdits.isNotEmpty() }) {
|
||||||
runCatching { reminderScheduler.sync() }
|
runCatching { reminderScheduler.sync() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
|||||||
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.agendula.data.sync.AccountStateStore
|
import de.jeanlucmakiola.agendula.data.sync.AccountStateStore
|
||||||
import de.jeanlucmakiola.agendula.data.sync.CredentialStore
|
import de.jeanlucmakiola.agendula.data.sync.CredentialStore
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.ServerTrustStore
|
||||||
import de.jeanlucmakiola.agendula.data.sync.SyncAvailability
|
import de.jeanlucmakiola.agendula.data.sync.SyncAvailability
|
||||||
import de.jeanlucmakiola.agendula.data.sync.SyncReport
|
import de.jeanlucmakiola.agendula.data.sync.SyncReport
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
|
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
|
||||||
@@ -48,6 +49,7 @@ class PushRegistrar @Inject constructor(
|
|||||||
private val credentials: CredentialStore,
|
private val credentials: CredentialStore,
|
||||||
private val accountState: AccountStateStore,
|
private val accountState: AccountStateStore,
|
||||||
private val availability: SyncAvailability,
|
private val availability: SyncAvailability,
|
||||||
|
private val trust: ServerTrustStore,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@@ -231,7 +233,7 @@ class PushRegistrar @Inject constructor(
|
|||||||
val username = account.username ?: return null
|
val username = account.username ?: return null
|
||||||
val origin = account.principalUrl?.toHttpUrlOrNull() ?: return null
|
val origin = account.principalUrl?.toHttpUrlOrNull() ?: return null
|
||||||
val password = (credentials.get(account.id) as? CredentialStore.Secret.Present)?.value ?: return null
|
val password = (credentials.get(account.id) as? CredentialStore.Secret.Present)?.value ?: return null
|
||||||
return CalDavHttp.authenticated(USER_AGENT, username, password, origin)
|
return CalDavHttp.authenticated(USER_AGENT, username, password, origin, trust.get(account.id))
|
||||||
.newBuilder()
|
.newBuilder()
|
||||||
.callTimeout(CALL_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
.callTimeout(CALL_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||||
.build()
|
.build()
|
||||||
|
|||||||
@@ -15,9 +15,12 @@ import androidx.compose.material.icons.rounded.Check
|
|||||||
import androidx.compose.material.icons.rounded.Bolt
|
import androidx.compose.material.icons.rounded.Bolt
|
||||||
import androidx.compose.material.icons.rounded.CloudSync
|
import androidx.compose.material.icons.rounded.CloudSync
|
||||||
import androidx.compose.material.icons.rounded.DeleteOutline
|
import androidx.compose.material.icons.rounded.DeleteOutline
|
||||||
|
import androidx.compose.material.icons.rounded.Edit
|
||||||
|
import androidx.compose.material.icons.rounded.GppMaybe
|
||||||
import androidx.compose.material.icons.rounded.History
|
import androidx.compose.material.icons.rounded.History
|
||||||
import androidx.compose.material.icons.rounded.HistoryToggleOff
|
import androidx.compose.material.icons.rounded.HistoryToggleOff
|
||||||
import androidx.compose.material.icons.rounded.Inventory2
|
import androidx.compose.material.icons.rounded.Inventory2
|
||||||
|
import androidx.compose.material.icons.rounded.LockOpen
|
||||||
import androidx.compose.material.icons.rounded.Refresh
|
import androidx.compose.material.icons.rounded.Refresh
|
||||||
import androidx.compose.material.icons.rounded.SyncProblem
|
import androidx.compose.material.icons.rounded.SyncProblem
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
@@ -26,6 +29,7 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -46,9 +50,13 @@ import de.jeanlucmakiola.agendula.data.sync.DiscardedEdit
|
|||||||
import de.jeanlucmakiola.agendula.data.sync.SyncNotice
|
import de.jeanlucmakiola.agendula.data.sync.SyncNotice
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
|
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
|
||||||
import de.jeanlucmakiola.agendula.ui.accounts.add.message
|
import de.jeanlucmakiola.agendula.ui.accounts.add.message
|
||||||
|
import de.jeanlucmakiola.agendula.ui.common.NameField
|
||||||
import de.jeanlucmakiola.agendula.ui.common.OnResume
|
import de.jeanlucmakiola.agendula.ui.common.OnResume
|
||||||
|
import de.jeanlucmakiola.caldav.ServerCertificate
|
||||||
|
import de.jeanlucmakiola.caldav.ServerTrust
|
||||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedHint
|
||||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
@@ -75,6 +83,8 @@ internal fun AccountDetailScreen(
|
|||||||
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
|
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
|
||||||
val usable by viewModel.accountsUsable.collectAsStateWithLifecycle()
|
val usable by viewModel.accountsUsable.collectAsStateWithLifecycle()
|
||||||
val push by viewModel.push.collectAsStateWithLifecycle()
|
val push by viewModel.push.collectAsStateWithLifecycle()
|
||||||
|
val trust by remember(accountId) { viewModel.trust(accountId) }
|
||||||
|
.collectAsStateWithLifecycle(ServerTrust.DEFAULT)
|
||||||
OnResume(viewModel::refreshPush)
|
OnResume(viewModel::refreshPush)
|
||||||
val row = accounts?.firstOrNull { it.account.id == accountId }
|
val row = accounts?.firstOrNull { it.account.id == accountId }
|
||||||
|
|
||||||
@@ -90,12 +100,34 @@ internal fun AccountDetailScreen(
|
|||||||
// The confirmation sheet. The removal it starts outlives this screen, and is
|
// The confirmation sheet. The removal it starts outlives this screen, and is
|
||||||
// shown on the accounts list rather than here.
|
// shown on the accounts list rather than here.
|
||||||
var confirming by remember { mutableStateOf(false) }
|
var confirming by remember { mutableStateOf(false) }
|
||||||
|
var renaming by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
if (renaming) {
|
||||||
|
AccountNamePicker(
|
||||||
|
current = account.label.orEmpty(),
|
||||||
|
defaultTitle = account.defaultTitle(),
|
||||||
|
onSave = { name ->
|
||||||
|
viewModel.rename(account, name)
|
||||||
|
renaming = false
|
||||||
|
},
|
||||||
|
onDismiss = { renaming = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
CollapsingScaffold(title = identity.title, onBack = onBack, predictiveBack = true, visible = visible) {
|
CollapsingScaffold(title = identity.title, onBack = onBack, predictiveBack = true, visible = visible) {
|
||||||
AccountHero(identity)
|
AccountHero(identity)
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
val actions = buildList<@Composable (Position) -> Unit> {
|
val actions = buildList<@Composable (Position) -> Unit> {
|
||||||
|
add { position ->
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.accounts_name),
|
||||||
|
summary = identity.title,
|
||||||
|
position = position,
|
||||||
|
leading = { Icon(Icons.Rounded.Edit, contentDescription = null) },
|
||||||
|
onClick = { renaming = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
add { position ->
|
add { position ->
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.accounts_last_sync),
|
title = stringResource(R.string.accounts_last_sync),
|
||||||
@@ -104,6 +136,28 @@ internal fun AccountDetailScreen(
|
|||||||
leading = { Icon(Icons.Rounded.History, contentDescription = null) },
|
leading = { Icon(Icons.Rounded.History, contentDescription = null) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (trust.allowCleartext) {
|
||||||
|
add { position ->
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.accounts_connection),
|
||||||
|
summary = stringResource(R.string.accounts_connection_cleartext),
|
||||||
|
position = position,
|
||||||
|
leading = { Icon(Icons.Rounded.LockOpen, contentDescription = null) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (trust.pinnedCertificates.isNotEmpty()) {
|
||||||
|
add { position ->
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.accounts_trusted_certificate),
|
||||||
|
summary = trust.pinnedCertificates.sorted().joinToString("\n") {
|
||||||
|
ServerCertificate.formatFingerprint(it.take(PINNED_PREFIX_CHARS)) + ":\u2026"
|
||||||
|
},
|
||||||
|
position = position,
|
||||||
|
leading = { Icon(Icons.Rounded.GppMaybe, contentDescription = null) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!usable) return@buildList
|
if (!usable) return@buildList
|
||||||
push?.let { pushState ->
|
push?.let { pushState ->
|
||||||
add { position ->
|
add { position ->
|
||||||
@@ -115,7 +169,7 @@ internal fun AccountDetailScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (shown.needsSignIn) {
|
if (shown.offersSignIn) {
|
||||||
add { position ->
|
add { position ->
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.accounts_sign_in_again),
|
title = stringResource(R.string.accounts_sign_in_again),
|
||||||
@@ -124,7 +178,8 @@ internal fun AccountDetailScreen(
|
|||||||
onClick = { onSignInAgain(account) },
|
onClick = { onSignInAgain(account) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
|
if (!shown.needsSignIn) {
|
||||||
add { position ->
|
add { position ->
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.accounts_sync_now),
|
title = stringResource(R.string.accounts_sync_now),
|
||||||
@@ -480,3 +535,33 @@ private fun errorTitle(text: String): AnnotatedString {
|
|||||||
buildAnnotatedString { withStyle(SpanStyle(color = error)) { append(text) } }
|
buildAnnotatedString { withStyle(SpanStyle(color = error)) { append(text) } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Names an account on screen (#105); left empty, it goes back to [defaultTitle]. */
|
||||||
|
@Composable
|
||||||
|
private fun AccountNamePicker(
|
||||||
|
current: String,
|
||||||
|
defaultTitle: String,
|
||||||
|
onSave: (String) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
var name by remember { mutableStateOf(current) }
|
||||||
|
FullScreenPicker(
|
||||||
|
title = stringResource(R.string.accounts_name),
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
actions = {
|
||||||
|
TextButton(onClick = { onSave(name) }) { Text(stringResource(R.string.save)) }
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
NameField(
|
||||||
|
name = name,
|
||||||
|
placeholder = defaultTitle,
|
||||||
|
autoFocus = true,
|
||||||
|
onNameChange = { name = it },
|
||||||
|
onImeAction = { onSave(name) },
|
||||||
|
)
|
||||||
|
GroupedHint(stringResource(R.string.accounts_name_hint, defaultTitle))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The first eight bytes of a pinned fingerprint, enough to compare at a glance. */
|
||||||
|
private const val PINNED_PREFIX_CHARS = 16
|
||||||
@@ -44,6 +44,14 @@ internal data class AccountIdentity(
|
|||||||
val user: String?,
|
val user: String?,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** The title an account has without a [AccountEntity.label]. */
|
||||||
|
internal fun AccountEntity.defaultTitle(): String {
|
||||||
|
val url = principalUrl?.toHttpUrlOrNull()
|
||||||
|
val provider = url?.let { CalDavProvider.forPrincipal(it) }
|
||||||
|
val host = url?.host?.removePrefix("www.")
|
||||||
|
return if (provider?.hosted == true) provider.label else host ?: displayName
|
||||||
|
}
|
||||||
|
|
||||||
internal fun AccountEntity.identity(): AccountIdentity {
|
internal fun AccountEntity.identity(): AccountIdentity {
|
||||||
val url = principalUrl?.toHttpUrlOrNull()
|
val url = principalUrl?.toHttpUrlOrNull()
|
||||||
val provider = url?.let { CalDavProvider.forPrincipal(it) }
|
val provider = url?.let { CalDavProvider.forPrincipal(it) }
|
||||||
@@ -53,7 +61,7 @@ internal fun AccountEntity.identity(): AccountIdentity {
|
|||||||
provider = provider,
|
provider = provider,
|
||||||
// The fallback is the stored name: an account with no principal URL
|
// The fallback is the stored name: an account with no principal URL
|
||||||
// never got past discovery, so there is nothing better to call it.
|
// never got past discovery, so there is nothing better to call it.
|
||||||
title = if (hosted) provider.label else host ?: displayName,
|
title = label ?: defaultTitle(),
|
||||||
secondary = if (hosted) host else provider?.label,
|
secondary = if (hosted) host else provider?.label,
|
||||||
user = username?.takeIf { it.isNotBlank() },
|
user = username?.takeIf { it.isNotBlank() },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ internal fun AccountsScreen(
|
|||||||
when {
|
when {
|
||||||
row.removing -> CircularProgressIndicator(Modifier.size(20.dp))
|
row.removing -> CircularProgressIndicator(Modifier.size(20.dp))
|
||||||
!usable -> Unit
|
!usable -> Unit
|
||||||
row.needsSignIn -> IconButton(onClick = { onSignInAgain(account) }) {
|
row.offersSignIn -> IconButton(onClick = { onSignInAgain(account) }) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.AutoMirrored.Rounded.Login,
|
Icons.AutoMirrored.Rounded.Login,
|
||||||
contentDescription = stringResource(R.string.accounts_sign_in_again),
|
contentDescription = stringResource(R.string.accounts_sign_in_again),
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import de.jeanlucmakiola.agendula.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
|
|||||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.agendula.data.sync.AccountRepository
|
import de.jeanlucmakiola.agendula.data.sync.AccountRepository
|
||||||
import de.jeanlucmakiola.agendula.data.sync.AccountStateStore
|
import de.jeanlucmakiola.agendula.data.sync.AccountStateStore
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.ServerTrustStore
|
||||||
import de.jeanlucmakiola.agendula.data.sync.SyncAvailability
|
import de.jeanlucmakiola.agendula.data.sync.SyncAvailability
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.SyncFailure
|
||||||
import de.jeanlucmakiola.agendula.data.sync.SyncNotice
|
import de.jeanlucmakiola.agendula.data.sync.SyncNotice
|
||||||
import de.jeanlucmakiola.agendula.data.sync.SyncNoticeStore
|
import de.jeanlucmakiola.agendula.data.sync.SyncNoticeStore
|
||||||
import de.jeanlucmakiola.agendula.data.sync.SyncTrigger
|
import de.jeanlucmakiola.agendula.data.sync.SyncTrigger
|
||||||
@@ -30,6 +32,7 @@ import kotlinx.coroutines.flow.stateIn
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import de.jeanlucmakiola.caldav.CalDavDiscovery
|
import de.jeanlucmakiola.caldav.CalDavDiscovery
|
||||||
|
import de.jeanlucmakiola.caldav.ServerTrust
|
||||||
import de.jeanlucmakiola.caldav.TaskCollection
|
import de.jeanlucmakiola.caldav.TaskCollection
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -43,6 +46,7 @@ class AccountsViewModel @Inject constructor(
|
|||||||
private val settings: SettingsPrefs,
|
private val settings: SettingsPrefs,
|
||||||
private val distributors: PushDistributors,
|
private val distributors: PushDistributors,
|
||||||
private val pushRegistrar: PushRegistrar,
|
private val pushRegistrar: PushRegistrar,
|
||||||
|
private val serverTrust: ServerTrustStore,
|
||||||
pushStore: PushStore,
|
pushStore: PushStore,
|
||||||
availability: SyncAvailability,
|
availability: SyncAvailability,
|
||||||
@IoDispatcher io: CoroutineDispatcher,
|
@IoDispatcher io: CoroutineDispatcher,
|
||||||
@@ -188,7 +192,18 @@ class AccountsViewModel @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
val notices: List<SyncNotice> = emptyList(),
|
val notices: List<SyncNotice> = emptyList(),
|
||||||
val lists: ListPush = ListPush.NONE,
|
val lists: ListPush = ListPush.NONE,
|
||||||
)
|
) {
|
||||||
|
/**
|
||||||
|
* The last sync was refused over the server's certificate — typically a
|
||||||
|
* pinned one that was renewed. Nothing sets [needsSignIn] for that, yet
|
||||||
|
* signing in again is the only route that can trust the new one.
|
||||||
|
*/
|
||||||
|
val certificateRefused: Boolean
|
||||||
|
get() = account.lastSyncError?.let { SyncFailure.of(it).kind == SyncFailure.Kind.CERTIFICATE } == true
|
||||||
|
|
||||||
|
/** Whether "Sign in again" is the way forward for this account. */
|
||||||
|
val offersSignIn: Boolean get() = needsSignIn || certificateRefused
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accounts whose removal has started and not finished.
|
* Accounts whose removal has started and not finished.
|
||||||
@@ -248,6 +263,13 @@ class AccountsViewModel @Inject constructor(
|
|||||||
syncTrigger.enqueue(account.displayName, expedited = true)
|
syncTrigger.enqueue(account.displayName, expedited = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun rename(account: AccountEntity, label: String) {
|
||||||
|
viewModelScope.launch { repository.setLabel(account.id, label) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the user agreed to trust for [accountId] beyond the platform's defaults. */
|
||||||
|
fun trust(accountId: Long): Flow<ServerTrust> = serverTrust.observe(accountId)
|
||||||
|
|
||||||
/** The user has read what the last sync changed. */
|
/** The user has read what the last sync changed. */
|
||||||
fun dismissNotices(account: AccountEntity) {
|
fun dismissNotices(account: AccountEntity) {
|
||||||
viewModelScope.launch { notices.dismiss(account.id) }
|
viewModelScope.launch { notices.dismiss(account.id) }
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
|||||||
import androidx.compose.material.icons.rounded.Checklist
|
import androidx.compose.material.icons.rounded.Checklist
|
||||||
import androidx.compose.material.icons.rounded.CloudOff
|
import androidx.compose.material.icons.rounded.CloudOff
|
||||||
import androidx.compose.material.icons.rounded.CloudSync
|
import androidx.compose.material.icons.rounded.CloudSync
|
||||||
|
import androidx.compose.material.icons.rounded.GppMaybe
|
||||||
import androidx.compose.material.icons.rounded.Lock
|
import androidx.compose.material.icons.rounded.Lock
|
||||||
|
import androidx.compose.material.icons.rounded.LockOpen
|
||||||
import androidx.compose.material.icons.rounded.OpenInBrowser
|
import androidx.compose.material.icons.rounded.OpenInBrowser
|
||||||
import androidx.compose.material.icons.rounded.Warning
|
import androidx.compose.material.icons.rounded.Warning
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -179,6 +181,8 @@ internal fun AddAccountScreen(
|
|||||||
is AddAccountStep.Working -> WorkingStep(step)
|
is AddAccountStep.Working -> WorkingStep(step)
|
||||||
is AddAccountStep.EnterCredentials -> CredentialsStep(step, viewModel)
|
is AddAccountStep.EnterCredentials -> CredentialsStep(step, viewModel)
|
||||||
is AddAccountStep.ConfirmBrowser -> ConfirmBrowserStep(step)
|
is AddAccountStep.ConfirmBrowser -> ConfirmBrowserStep(step)
|
||||||
|
is AddAccountStep.ConfirmCleartext -> ConfirmCleartextStep(step)
|
||||||
|
is AddAccountStep.TrustCertificate -> TrustCertificateStep(step)
|
||||||
is AddAccountStep.WaitingForBrowser -> BrowserStep(step)
|
is AddAccountStep.WaitingForBrowser -> BrowserStep(step)
|
||||||
is AddAccountStep.ChooseLists -> ListsStep(step, viewModel)
|
is AddAccountStep.ChooseLists -> ListsStep(step, viewModel)
|
||||||
is AddAccountStep.Summary -> SummaryStep(step)
|
is AddAccountStep.Summary -> SummaryStep(step)
|
||||||
@@ -228,7 +232,11 @@ private fun AddAccountStep.position(hasSetup: Boolean): Int? {
|
|||||||
return when (this) {
|
return when (this) {
|
||||||
is AddAccountStep.ChooseProvider -> 1
|
is AddAccountStep.ChooseProvider -> 1
|
||||||
is AddAccountStep.PrepareAccess -> 2
|
is AddAccountStep.PrepareAccess -> 2
|
||||||
is AddAccountStep.EnterAddress -> 2 + errand
|
// Both are about reaching the server, so they keep the address's slot.
|
||||||
|
is AddAccountStep.EnterAddress,
|
||||||
|
is AddAccountStep.ConfirmCleartext,
|
||||||
|
is AddAccountStep.TrustCertificate,
|
||||||
|
-> 2 + errand
|
||||||
// One slot, because only one of the three ever happens: a server either
|
// One slot, because only one of the three ever happens: a server either
|
||||||
// hands the sign-in to a browser — asking first, where the URL needs
|
// hands the sign-in to a browser — asking first, where the URL needs
|
||||||
// confirming — or asks for a password.
|
// confirming — or asks for a password.
|
||||||
@@ -272,6 +280,8 @@ private fun StepHero(state: AddAccountUiState) {
|
|||||||
// The warning is the screen, so it is the mark too — the browser icon
|
// The warning is the screen, so it is the mark too — the browser icon
|
||||||
// would say "this is going fine", which is the opposite of the point.
|
// would say "this is going fine", which is the opposite of the point.
|
||||||
state.step is AddAccountStep.ConfirmBrowser -> Icons.Rounded.Warning
|
state.step is AddAccountStep.ConfirmBrowser -> Icons.Rounded.Warning
|
||||||
|
state.step is AddAccountStep.ConfirmCleartext -> Icons.Rounded.LockOpen
|
||||||
|
state.step is AddAccountStep.TrustCertificate -> Icons.Rounded.GppMaybe
|
||||||
state.step is AddAccountStep.WaitingForBrowser -> Icons.Rounded.OpenInBrowser
|
state.step is AddAccountStep.WaitingForBrowser -> Icons.Rounded.OpenInBrowser
|
||||||
state.step is AddAccountStep.ChooseLists -> Icons.Rounded.Checklist
|
state.step is AddAccountStep.ChooseLists -> Icons.Rounded.Checklist
|
||||||
else -> Icons.Rounded.CloudSync
|
else -> Icons.Rounded.CloudSync
|
||||||
@@ -311,6 +321,10 @@ private fun StepTitle(step: AddAccountStep) {
|
|||||||
R.string.add_account_credentials_title to R.string.add_account_credentials_body
|
R.string.add_account_credentials_title to R.string.add_account_credentials_body
|
||||||
is AddAccountStep.ConfirmBrowser ->
|
is AddAccountStep.ConfirmBrowser ->
|
||||||
R.string.add_account_browser_confirm_title to R.string.add_account_browser_confirm_body
|
R.string.add_account_browser_confirm_title to R.string.add_account_browser_confirm_body
|
||||||
|
is AddAccountStep.ConfirmCleartext ->
|
||||||
|
R.string.add_account_cleartext_title to R.string.add_account_cleartext_body
|
||||||
|
is AddAccountStep.TrustCertificate ->
|
||||||
|
R.string.add_account_certificate_title to R.string.add_account_certificate_body
|
||||||
is AddAccountStep.ChooseLists ->
|
is AddAccountStep.ChooseLists ->
|
||||||
R.string.add_account_lists_title to R.string.add_account_lists_body
|
R.string.add_account_lists_title to R.string.add_account_lists_body
|
||||||
is AddAccountStep.Summary ->
|
is AddAccountStep.Summary ->
|
||||||
@@ -412,6 +426,34 @@ private fun ColumnScope.StepActions(state: AddAccountUiState, viewModel: AddAcco
|
|||||||
) { Text(stringResource(R.string.add_account_browser_use_password)) }
|
) { Text(stringResource(R.string.add_account_browser_use_password)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Plain http stays a deliberate choice: going forward is primary, as it
|
||||||
|
// is for the browser warning, and changing the address is right below.
|
||||||
|
is AddAccountStep.ConfirmCleartext -> {
|
||||||
|
PrimaryAction(
|
||||||
|
label = stringResource(R.string.add_account_cleartext_connect),
|
||||||
|
enabled = true,
|
||||||
|
onClick = viewModel::onCleartextConfirmed,
|
||||||
|
)
|
||||||
|
TextButton(
|
||||||
|
onClick = { viewModel.onBackWithin() },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) { Text(stringResource(R.string.add_account_change_address)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The safe answer stays visible, as it does for plain http: back is
|
||||||
|
// not something every user knows to look for on a security prompt.
|
||||||
|
is AddAccountStep.TrustCertificate -> {
|
||||||
|
PrimaryAction(
|
||||||
|
label = stringResource(R.string.add_account_certificate_trust),
|
||||||
|
enabled = true,
|
||||||
|
onClick = viewModel::onCertificateTrusted,
|
||||||
|
)
|
||||||
|
TextButton(
|
||||||
|
onClick = { viewModel.onBackWithin() },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) { Text(stringResource(R.string.add_account_certificate_decline)) }
|
||||||
|
}
|
||||||
|
|
||||||
is AddAccountStep.WaitingForBrowser -> {
|
is AddAccountStep.WaitingForBrowser -> {
|
||||||
// A failed flow is retried by default. With no browser a retry
|
// A failed flow is retried by default. With no browser a retry
|
||||||
// cannot help; while the server throttles or is in maintenance an
|
// cannot help; while the server throttles or is in maintenance an
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ internal val CalDavDiscovery.Outcome.Cause.message: Int
|
|||||||
CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER -> R.string.add_account_error_not_dav
|
CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER -> R.string.add_account_error_not_dav
|
||||||
CalDavDiscovery.Outcome.Cause.NO_CALENDAR_SUPPORT -> R.string.add_account_error_no_calendar
|
CalDavDiscovery.Outcome.Cause.NO_CALENDAR_SUPPORT -> R.string.add_account_error_no_calendar
|
||||||
CalDavDiscovery.Outcome.Cause.UNREACHABLE -> R.string.add_account_error_unreachable
|
CalDavDiscovery.Outcome.Cause.UNREACHABLE -> R.string.add_account_error_unreachable
|
||||||
|
CalDavDiscovery.Outcome.Cause.TLS -> R.string.add_account_error_tls
|
||||||
CalDavDiscovery.Outcome.Cause.INSECURE -> R.string.add_account_error_insecure
|
CalDavDiscovery.Outcome.Cause.INSECURE -> R.string.add_account_error_insecure
|
||||||
CalDavDiscovery.Outcome.Cause.NO_CALENDARS -> R.string.add_account_error_no_calendars
|
CalDavDiscovery.Outcome.Cause.NO_CALENDARS -> R.string.add_account_error_no_calendars
|
||||||
CalDavDiscovery.Outcome.Cause.SERVER_ERROR -> R.string.add_account_error_server
|
CalDavDiscovery.Outcome.Cause.SERVER_ERROR -> R.string.add_account_error_server
|
||||||
|
|||||||
+230
-50
@@ -8,13 +8,18 @@ import de.jeanlucmakiola.agendula.data.sync.AccountCreator
|
|||||||
import de.jeanlucmakiola.agendula.data.sync.AccountRepository
|
import de.jeanlucmakiola.agendula.data.sync.AccountRepository
|
||||||
import de.jeanlucmakiola.agendula.data.sync.CalDavGateway
|
import de.jeanlucmakiola.agendula.data.sync.CalDavGateway
|
||||||
import de.jeanlucmakiola.agendula.data.sync.LoginFlowRecord
|
import de.jeanlucmakiola.agendula.data.sync.LoginFlowRecord
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.ServerTrustKeys
|
||||||
import de.jeanlucmakiola.caldav.CalDavDiscovery
|
import de.jeanlucmakiola.caldav.CalDavDiscovery
|
||||||
import de.jeanlucmakiola.caldav.CalDavProvider
|
import de.jeanlucmakiola.caldav.CalDavProvider
|
||||||
import de.jeanlucmakiola.caldav.NextcloudLoginFlow
|
import de.jeanlucmakiola.caldav.NextcloudLoginFlow
|
||||||
|
import de.jeanlucmakiola.caldav.ServerCertificate
|
||||||
import de.jeanlucmakiola.caldav.ServerQuirk
|
import de.jeanlucmakiola.caldav.ServerQuirk
|
||||||
|
import de.jeanlucmakiola.caldav.ServerTrust
|
||||||
import de.jeanlucmakiola.caldav.ServiceDiscovery
|
import de.jeanlucmakiola.caldav.ServiceDiscovery
|
||||||
import de.jeanlucmakiola.caldav.TaskCollection
|
import de.jeanlucmakiola.caldav.TaskCollection
|
||||||
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.NonCancellable
|
import kotlinx.coroutines.NonCancellable
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -116,6 +121,12 @@ sealed interface AddAccountStep {
|
|||||||
val insecure: Boolean = false,
|
val insecure: Boolean = false,
|
||||||
) : AddAccountStep
|
) : AddAccountStep
|
||||||
|
|
||||||
|
/** The typed address is plain http; the user has to accept that before anything is sent. */
|
||||||
|
data class ConfirmCleartext(val address: String) : AddAccountStep
|
||||||
|
|
||||||
|
/** The server's certificate is not trusted by the platform; the user may pin it. */
|
||||||
|
data class TrustCertificate(val certificate: ServerCertificate) : AddAccountStep
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The browser has the flow. We poll until the user approves, and offer an
|
* The browser has the flow. We poll until the user approves, and offer an
|
||||||
* explicit way out — Custom Tabs return **no result** when dismissed, and
|
* explicit way out — Custom Tabs return **no result** when dismissed, and
|
||||||
@@ -327,6 +338,36 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
/** The flow the browser was last sent to, for a retry while it is still open. */
|
/** The flow the browser was last sent to, for a retry while it is still open. */
|
||||||
private var browserFlow: NextcloudLoginFlow.Flow? = null
|
private var browserFlow: NextcloudLoginFlow.Flow? = null
|
||||||
|
|
||||||
|
/** What the user agreed to trust in this flow, handed to every gateway call. */
|
||||||
|
private var trust: ServerTrust
|
||||||
|
get() = TRUST_KEYS.read(handle)
|
||||||
|
set(value) = TRUST_KEYS.write(handle, value)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The address [trust] was given for. Pins and plain http both belong to it,
|
||||||
|
* so submitting any other address starts from nothing.
|
||||||
|
*/
|
||||||
|
private var trustAddress: String?
|
||||||
|
get() = handle[KEY_TRUST_ADDRESS]
|
||||||
|
set(value) { handle[KEY_TRUST_ADDRESS] = value }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pins in [trust] came from the account being signed in to, not from
|
||||||
|
* this flow. The first certificate trusted now replaces them, so a renewed
|
||||||
|
* certificate retires the one it renews instead of joining it.
|
||||||
|
*/
|
||||||
|
private var inheritedPins: Boolean
|
||||||
|
get() = handle[KEY_INHERITED_PINS] ?: false
|
||||||
|
set(value) { handle[KEY_INHERITED_PINS] = value }
|
||||||
|
|
||||||
|
/** Loading a re-authenticated account's stored trust, which discovery waits for. */
|
||||||
|
private var trustLoad: Job? = null
|
||||||
|
|
||||||
|
/** The open [AddAccountStep.TrustCertificate] question, and where declining it goes. */
|
||||||
|
private class TrustPrompt(val answer: CompletableDeferred<Unit>, val back: AddAccountStep)
|
||||||
|
|
||||||
|
private var trustPrompt: TrustPrompt? = null
|
||||||
|
|
||||||
/** Which hosts asked for credentials, for the cross-domain diagnostic. */
|
/** Which hosts asked for credentials, for the cross-domain diagnostic. */
|
||||||
private var hostsNeedingAuth: List<String>
|
private var hostsNeedingAuth: List<String>
|
||||||
get() = handle.get<ArrayList<String>>(KEY_HOSTS_NEEDING_AUTH).orEmpty()
|
get() = handle.get<ArrayList<String>>(KEY_HOSTS_NEEDING_AUTH).orEmpty()
|
||||||
@@ -424,6 +465,15 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
reauthenticating = true,
|
reauthenticating = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Starts from what the account already trusts, so a renewed certificate
|
||||||
|
// is the only thing asked about again.
|
||||||
|
val address = addressInput
|
||||||
|
trustLoad = viewModelScope.launch {
|
||||||
|
val stored = runCatching { repository.trustFor(accountId) }.getOrDefault(ServerTrust.DEFAULT)
|
||||||
|
trust = stored
|
||||||
|
trustAddress = address
|
||||||
|
inheritedPins = stored.pinnedCertificates.isNotEmpty()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The errand is read; on to the address. */
|
/** The errand is read; on to the address. */
|
||||||
@@ -498,10 +548,25 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
serverRoot = ServiceDiscovery.serverRootFor(input)
|
serverRoot = ServiceDiscovery.serverRootFor(input)
|
||||||
|
discoverAddress()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Discovery for the submitted address, without credentials. */
|
||||||
|
private fun discoverAddress() {
|
||||||
working(AddAccountMessage.Progress.Discovering)
|
working(AddAccountMessage.Progress.Discovering)
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
when (val outcome = gateway.discover(typedInput)) {
|
// ⚠️ After the stored trust has landed, not before: reset first and
|
||||||
|
// the load would put the old address's trust back on top.
|
||||||
|
trustLoad?.join()
|
||||||
|
if (typedInput != trustAddress) {
|
||||||
|
trust = ServerTrust.DEFAULT
|
||||||
|
inheritedPins = false
|
||||||
|
trustAddress = typedInput
|
||||||
|
}
|
||||||
|
val outcome = discoverTrusting(addressStep(typedInput), AddAccountMessage.Progress.Discovering) {
|
||||||
|
gateway.discover(typedInput, trust = trust)
|
||||||
|
} ?: return@launch
|
||||||
|
when (outcome) {
|
||||||
is CalDavDiscovery.Outcome.Found -> onDiscovered(outcome)
|
is CalDavDiscovery.Outcome.Found -> onDiscovered(outcome)
|
||||||
|
|
||||||
is CalDavDiscovery.Outcome.NeedsAuthentication -> {
|
is CalDavDiscovery.Outcome.NeedsAuthentication -> {
|
||||||
@@ -517,11 +582,71 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
|
|
||||||
is CalDavDiscovery.Outcome.NotCalDav -> backToAddressOrServer(outcome.cause)
|
is CalDavDiscovery.Outcome.NotCalDav -> backToAddressOrServer(outcome.cause)
|
||||||
|
|
||||||
is CalDavDiscovery.Outcome.Failed -> backToAddressOrServer(outcome.cause)
|
is CalDavDiscovery.Outcome.Failed -> if (needsCleartextConsent(outcome.cause)) {
|
||||||
|
updateState { it.copy(step = AddAccountStep.ConfirmCleartext(typedInput)) }
|
||||||
|
} else {
|
||||||
|
backToAddressOrServer(outcome.cause)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asked about inside discoverTrusting, which only returns it declined.
|
||||||
|
is CalDavDiscovery.Outcome.UntrustedCertificate -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A typed http:// address the user has not yet accepted as unencrypted. */
|
||||||
|
private fun needsCleartextConsent(cause: CalDavDiscovery.Outcome.Cause): Boolean =
|
||||||
|
cause == CalDavDiscovery.Outcome.Cause.INSECURE && !trust.allowCleartext &&
|
||||||
|
typedInput.toHttpUrlOrNull()?.isHttps == false
|
||||||
|
|
||||||
|
/** The warning is read and plain http is wanted anyway. */
|
||||||
|
fun onCleartextConfirmed() {
|
||||||
|
if (current.step !is AddAccountStep.ConfirmCleartext) return
|
||||||
|
trust = trust.copy(allowCleartext = true)
|
||||||
|
trustAddress = typedInput
|
||||||
|
discoverAddress()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The fingerprint is checked; pin it and re-run what it stopped. */
|
||||||
|
fun onCertificateTrusted() {
|
||||||
|
val step = current.step as? AddAccountStep.TrustCertificate ?: return
|
||||||
|
val prompt = trustPrompt ?: return
|
||||||
|
trustPrompt = null
|
||||||
|
val fingerprint = step.certificate.fingerprint
|
||||||
|
trust = if (inheritedPins) {
|
||||||
|
trust.copy(pinnedCertificates = setOf(fingerprint.lowercase()))
|
||||||
|
} else {
|
||||||
|
trust.withCertificate(fingerprint)
|
||||||
|
}
|
||||||
|
inheritedPins = false
|
||||||
|
prompt.answer.complete(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs [discover], and on a certificate the user could trust asks about it
|
||||||
|
* and runs it again once they have. Null when they declined; the step is
|
||||||
|
* then already [back].
|
||||||
|
*/
|
||||||
|
private suspend fun discoverTrusting(
|
||||||
|
back: AddAccountStep,
|
||||||
|
progress: AddAccountMessage.Progress,
|
||||||
|
discover: suspend () -> CalDavDiscovery.Outcome,
|
||||||
|
): CalDavDiscovery.Outcome? {
|
||||||
|
while (true) {
|
||||||
|
val outcome = discover()
|
||||||
|
if (outcome !is CalDavDiscovery.Outcome.UntrustedCertificate) return outcome
|
||||||
|
val prompt = TrustPrompt(CompletableDeferred(), back)
|
||||||
|
trustPrompt = prompt
|
||||||
|
updateState { it.copy(step = AddAccountStep.TrustCertificate(outcome.certificate), openInBrowser = null) }
|
||||||
|
try {
|
||||||
|
prompt.answer.await()
|
||||||
|
} catch (_: CancellationException) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
working(progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An email address whose domain has no CalDAV server goes straight to the
|
* An email address whose domain has no CalDAV server goes straight to the
|
||||||
* server field rather than an error with nowhere to go; the email stays as
|
* server field rather than an error with nowhere to go; the email stays as
|
||||||
@@ -562,7 +687,10 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
if (step.username.isBlank() || step.password.isEmpty()) return
|
if (step.username.isBlank() || step.password.isEmpty()) return
|
||||||
username = step.username.trim()
|
username = step.username.trim()
|
||||||
appPassword = step.password
|
appPassword = step.password
|
||||||
|
discoverWithCredentials()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun discoverWithCredentials() {
|
||||||
working(AddAccountMessage.Progress.SigningIn)
|
working(AddAccountMessage.Progress.SigningIn)
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
// ⚠️ An error, not a silent unauthenticated retry. serverRootFor
|
// ⚠️ An error, not a silent unauthenticated retry. serverRootFor
|
||||||
@@ -573,7 +701,11 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
val root = serverRoot
|
val root = serverRoot
|
||||||
?: return@launch backToAddress(CalDavDiscovery.Outcome.Cause.NOT_AN_ADDRESS)
|
?: return@launch backToAddress(CalDavDiscovery.Outcome.Cause.NOT_AN_ADDRESS)
|
||||||
val credentials = CalDavGateway.Credentials(username, appPassword, root)
|
val credentials = CalDavGateway.Credentials(username, appPassword, root)
|
||||||
when (val outcome = gateway.discover(typedInput, credentials)) {
|
val outcome = discoverTrusting(
|
||||||
|
AddAccountStep.EnterCredentials(username = username),
|
||||||
|
AddAccountMessage.Progress.SigningIn,
|
||||||
|
) { gateway.discover(typedInput, credentials, trust) } ?: return@launch
|
||||||
|
when (outcome) {
|
||||||
is CalDavDiscovery.Outcome.Found -> onDiscovered(outcome)
|
is CalDavDiscovery.Outcome.Found -> onDiscovered(outcome)
|
||||||
|
|
||||||
// A second rejection is the point at which naming the provider's
|
// A second rejection is the point at which naming the provider's
|
||||||
@@ -595,6 +727,7 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
|
|
||||||
is CalDavDiscovery.Outcome.NotCalDav -> backToAddressOrServer(outcome.cause)
|
is CalDavDiscovery.Outcome.NotCalDav -> backToAddressOrServer(outcome.cause)
|
||||||
is CalDavDiscovery.Outcome.Failed -> backToAddressOrServer(outcome.cause)
|
is CalDavDiscovery.Outcome.Failed -> backToAddressOrServer(outcome.cause)
|
||||||
|
is CalDavDiscovery.Outcome.UntrustedCertificate -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -613,7 +746,7 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
/** Step 3b → the browser flow, if this looks like a Nextcloud. */
|
/** Step 3b → the browser flow, if this looks like a Nextcloud. */
|
||||||
private suspend fun offerSignIn() {
|
private suspend fun offerSignIn() {
|
||||||
val root = serverRoot ?: return backToAddress(CalDavDiscovery.Outcome.Cause.NOT_AN_ADDRESS)
|
val root = serverRoot ?: return backToAddress(CalDavDiscovery.Outcome.Cause.NOT_AN_ADDRESS)
|
||||||
val flow = gateway.startLoginFlow(root)
|
val flow = gateway.startLoginFlow(root, trust)
|
||||||
|
|
||||||
if (flow == null) {
|
if (flow == null) {
|
||||||
// Not a Nextcloud, or its login flow is unavailable. Ask for a
|
// Not a Nextcloud, or its login flow is unavailable. Ask for a
|
||||||
@@ -674,7 +807,7 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
private suspend fun launchBrowser(flow: NextcloudLoginFlow.Flow) {
|
private suspend fun launchBrowser(flow: NextcloudLoginFlow.Flow) {
|
||||||
browserFlow = flow
|
browserFlow = flow
|
||||||
runCatching { pendingFlow.remember(flow) }
|
runCatching { pendingFlow.remember(flow, trust) }
|
||||||
updateState {
|
updateState {
|
||||||
it.copy(
|
it.copy(
|
||||||
step = AddAccountStep.WaitingForBrowser(hostMismatch = flow.hostMismatch),
|
step = AddAccountStep.WaitingForBrowser(hostMismatch = flow.hostMismatch),
|
||||||
@@ -703,7 +836,7 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
// later poll can only report expiry. Bounded by the gateway's
|
// later poll can only report expiry. Bounded by the gateway's
|
||||||
// own per-call budget, and the delay above stays cancellable, so
|
// own per-call budget, and the delay above stays cancellable, so
|
||||||
// the loop still stops promptly.
|
// the loop still stops promptly.
|
||||||
val result = withContext(NonCancellable) { gateway.pollLoginFlow(flow) }
|
val result = withContext(NonCancellable) { gateway.pollLoginFlow(flow, trust) }
|
||||||
when (result) {
|
when (result) {
|
||||||
is NextcloudLoginFlow.PollResult.Approved -> {
|
is NextcloudLoginFlow.PollResult.Approved -> {
|
||||||
// ⚠️ Uncancellable. The 200 that carried this password is
|
// ⚠️ Uncancellable. The 200 that carried this password is
|
||||||
@@ -742,47 +875,7 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
result.hostMismatch?.let { mismatch ->
|
result.hostMismatch?.let { mismatch ->
|
||||||
updateState { it.copy(originMismatch = mismatch) }
|
updateState { it.copy(originMismatch = mismatch) }
|
||||||
}
|
}
|
||||||
working(AddAccountMessage.Progress.ReadingLists)
|
discoverApproved(result.credentials.server)
|
||||||
val outcome = gateway.discover(
|
|
||||||
// The server the credentials were *issued by*, not the
|
|
||||||
// host the user typed — the two differ in exactly the
|
|
||||||
// host-mismatch case this flow already models, and
|
|
||||||
// getting it wrong burns the one-shot app password.
|
|
||||||
target = result.credentials.server.toString(),
|
|
||||||
credentials = CalDavGateway.Credentials(
|
|
||||||
username,
|
|
||||||
appPassword,
|
|
||||||
result.credentials.server,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
when (outcome) {
|
|
||||||
is CalDavDiscovery.Outcome.Found -> onDiscovered(outcome)
|
|
||||||
// ⚠️ Forward what actually happened. Reporting every
|
|
||||||
// outcome as NO_CALENDARS tells someone whose server
|
|
||||||
// named an unresolvable host that their account holds
|
|
||||||
// no task lists, which is both wrong and unactionable.
|
|
||||||
is CalDavDiscovery.Outcome.Failed -> backToAddress(outcome.cause)
|
|
||||||
is CalDavDiscovery.Outcome.NotCalDav -> backToAddress(outcome.cause)
|
|
||||||
// ⚠️ These two carry no Cause, so they used to land
|
|
||||||
// on "signed in, but no task lists" — said of a
|
|
||||||
// credential the server had just rejected. A 401 here
|
|
||||||
// is either a home set outside the domain the
|
|
||||||
// credential is scoped to, which retrying cannot fix
|
|
||||||
// and which only mints a second password, or the
|
|
||||||
// server having a moment.
|
|
||||||
is CalDavDiscovery.Outcome.NeedsAuthentication -> {
|
|
||||||
hostsNeedingAuth = outcome.hosts
|
|
||||||
val stranger = outsideCredentialScope(outcome.hosts)
|
|
||||||
if (stranger != null) {
|
|
||||||
fatal(AddAccountMessage.OutsideCredentialScope(stranger))
|
|
||||||
} else {
|
|
||||||
backToAddress(CalDavDiscovery.Outcome.Cause.SERVER_ERROR)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
CalDavDiscovery.Outcome.Unauthenticated ->
|
|
||||||
backToAddress(CalDavDiscovery.Outcome.Cause.SERVER_ERROR)
|
|
||||||
}
|
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -815,6 +908,52 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Discovery with a password the login flow just minted, at the server that issued it. */
|
||||||
|
private suspend fun discoverApproved(server: HttpUrl) {
|
||||||
|
working(AddAccountMessage.Progress.ReadingLists)
|
||||||
|
val outcome = discoverTrusting(addressStep(typedInput), AddAccountMessage.Progress.ReadingLists) {
|
||||||
|
gateway.discover(
|
||||||
|
// The server the credentials were *issued by*, not the
|
||||||
|
// host the user typed — the two differ in exactly the
|
||||||
|
// host-mismatch case this flow already models, and
|
||||||
|
// getting it wrong burns the one-shot app password.
|
||||||
|
target = server.toString(),
|
||||||
|
credentials = CalDavGateway.Credentials(username, appPassword, server),
|
||||||
|
trust = trust,
|
||||||
|
)
|
||||||
|
} ?: return
|
||||||
|
when (outcome) {
|
||||||
|
is CalDavDiscovery.Outcome.Found -> onDiscovered(outcome)
|
||||||
|
// ⚠️ Forward what actually happened. Reporting every
|
||||||
|
// outcome as NO_CALENDARS tells someone whose server
|
||||||
|
// named an unresolvable host that their account holds
|
||||||
|
// no task lists, which is both wrong and unactionable.
|
||||||
|
is CalDavDiscovery.Outcome.Failed -> backToAddress(outcome.cause)
|
||||||
|
is CalDavDiscovery.Outcome.NotCalDav -> backToAddress(outcome.cause)
|
||||||
|
// ⚠️ These two carry no Cause, so they used to land
|
||||||
|
// on "signed in, but no task lists" — said of a
|
||||||
|
// credential the server had just rejected. A 401 here
|
||||||
|
// is either a home set outside the domain the
|
||||||
|
// credential is scoped to, which retrying cannot fix
|
||||||
|
// and which only mints a second password, or the
|
||||||
|
// server having a moment.
|
||||||
|
is CalDavDiscovery.Outcome.NeedsAuthentication -> {
|
||||||
|
hostsNeedingAuth = outcome.hosts
|
||||||
|
val stranger = outsideCredentialScope(outcome.hosts)
|
||||||
|
if (stranger != null) {
|
||||||
|
fatal(AddAccountMessage.OutsideCredentialScope(stranger))
|
||||||
|
} else {
|
||||||
|
backToAddress(CalDavDiscovery.Outcome.Cause.SERVER_ERROR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CalDavDiscovery.Outcome.Unauthenticated ->
|
||||||
|
backToAddress(CalDavDiscovery.Outcome.Cause.SERVER_ERROR)
|
||||||
|
|
||||||
|
is CalDavDiscovery.Outcome.UntrustedCertificate -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Another go after the browser sign-in failed.
|
* Another go after the browser sign-in failed.
|
||||||
*
|
*
|
||||||
@@ -898,6 +1037,7 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
found = discovered,
|
found = discovered,
|
||||||
selected = chosen,
|
selected = chosen,
|
||||||
reauthenticating = reauthAccountId,
|
reauthenticating = reauthAccountId,
|
||||||
|
trust = trust,
|
||||||
)
|
)
|
||||||
}.getOrElse {
|
}.getOrElse {
|
||||||
// A constraint violation or an IO failure in DataStore must not
|
// A constraint violation or an IO failure in DataStore must not
|
||||||
@@ -1004,6 +1144,19 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is AddAccountStep.ConfirmCleartext -> {
|
||||||
|
updateState { it.copy(step = addressStep(typedInput), fatal = null) }
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
is AddAccountStep.TrustCertificate -> {
|
||||||
|
val prompt = trustPrompt
|
||||||
|
trustPrompt = null
|
||||||
|
prompt?.answer?.cancel()
|
||||||
|
updateState { it.copy(step = prompt?.back ?: addressStep(typedInput), fatal = null) }
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1045,6 +1198,13 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
reauthAccountId = null
|
reauthAccountId = null
|
||||||
suggestedUsername = ""
|
suggestedUsername = ""
|
||||||
fallbackEmail = null
|
fallbackEmail = null
|
||||||
|
trustLoad?.cancel()
|
||||||
|
trustLoad = null
|
||||||
|
trust = ServerTrust.DEFAULT
|
||||||
|
trustAddress = null
|
||||||
|
inheritedPins = false
|
||||||
|
trustPrompt?.answer?.cancel()
|
||||||
|
trustPrompt = null
|
||||||
current = AddAccountUiState()
|
current = AddAccountUiState()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1068,9 +1228,10 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
private fun discardMintedPassword() {
|
private fun discardMintedPassword() {
|
||||||
val credentials = minted ?: return
|
val credentials = minted ?: return
|
||||||
minted = null
|
minted = null
|
||||||
|
val trust = trust
|
||||||
// The scope has no exception handler, so anything escaping here takes the
|
// The scope has no exception handler, so anything escaping here takes the
|
||||||
// app down — for a courtesy call whose failures are deliberately silent.
|
// app down — for a courtesy call whose failures are deliberately silent.
|
||||||
appScope.launch { runCatching { gateway.revokeIssuedAppPassword(credentials) } }
|
appScope.launch { runCatching { gateway.revokeIssuedAppPassword(credentials, trust) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------- saved state
|
// -------------------------------------------------------- saved state
|
||||||
@@ -1095,6 +1256,8 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
// what deals with that on the next open. Recording them as
|
// what deals with that on the next open. Recording them as
|
||||||
// themselves would only mean deciding the same thing twice.
|
// themselves would only mean deciding the same thing twice.
|
||||||
is AddAccountStep.ConfirmBrowser, is AddAccountStep.WaitingForBrowser -> STEP_ADDRESS
|
is AddAccountStep.ConfirmBrowser, is AddAccountStep.WaitingForBrowser -> STEP_ADDRESS
|
||||||
|
// Both re-ask on the next submit, which is where a restore puts them.
|
||||||
|
is AddAccountStep.ConfirmCleartext, is AddAccountStep.TrustCertificate -> STEP_ADDRESS
|
||||||
is AddAccountStep.Working -> STEP_WORKING
|
is AddAccountStep.Working -> STEP_WORKING
|
||||||
is AddAccountStep.ChooseLists -> STEP_LISTS
|
is AddAccountStep.ChooseLists -> STEP_LISTS
|
||||||
is AddAccountStep.Summary -> STEP_SUMMARY
|
is AddAccountStep.Summary -> STEP_SUMMARY
|
||||||
@@ -1200,7 +1363,10 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val credentials = CalDavGateway.Credentials(username, appPassword, root)
|
val credentials = CalDavGateway.Credentials(username, appPassword, root)
|
||||||
when (val outcome = gateway.discover(target, credentials)) {
|
val outcome = discoverTrusting(addressStep(addressInput), AddAccountMessage.Progress.ReadingLists) {
|
||||||
|
gateway.discover(target, credentials, trust)
|
||||||
|
} ?: return@launch
|
||||||
|
when (outcome) {
|
||||||
is CalDavDiscovery.Outcome.Found -> onDiscovered(outcome, selection)
|
is CalDavDiscovery.Outcome.Found -> onDiscovered(outcome, selection)
|
||||||
is CalDavDiscovery.Outcome.NotCalDav -> backToAddressOrServer(outcome.cause)
|
is CalDavDiscovery.Outcome.NotCalDav -> backToAddressOrServer(outcome.cause)
|
||||||
is CalDavDiscovery.Outcome.Failed -> backToAddressOrServer(outcome.cause)
|
is CalDavDiscovery.Outcome.Failed -> backToAddressOrServer(outcome.cause)
|
||||||
@@ -1211,6 +1377,7 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
credentialsRejected()
|
credentialsRejected()
|
||||||
}
|
}
|
||||||
CalDavDiscovery.Outcome.Unauthenticated -> credentialsRejected()
|
CalDavDiscovery.Outcome.Unauthenticated -> credentialsRejected()
|
||||||
|
is CalDavDiscovery.Outcome.UntrustedCertificate -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1386,10 +1553,14 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The port is part of the name: two servers on one host are two accounts (#105).
|
||||||
private fun accountName(): String =
|
private fun accountName(): String =
|
||||||
username.takeIf { it.isNotBlank() }?.let { "$it@${serverRoot?.host.orEmpty()}" }
|
username.takeIf { it.isNotBlank() }?.let { "$it@${serverRoot?.authority().orEmpty()}" }
|
||||||
?: typedInput
|
?: typedInput
|
||||||
|
|
||||||
|
private fun HttpUrl.authority(): String =
|
||||||
|
if (port == HttpUrl.defaultPort(scheme)) host else "$host:$port"
|
||||||
|
|
||||||
internal companion object {
|
internal companion object {
|
||||||
const val POLL_INTERVAL_MILLIS = 2_000L
|
const val POLL_INTERVAL_MILLIS = 2_000L
|
||||||
|
|
||||||
@@ -1434,12 +1605,21 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
const val KEY_REAUTH_ACCOUNT = "add_account.reauth_account"
|
const val KEY_REAUTH_ACCOUNT = "add_account.reauth_account"
|
||||||
const val KEY_SUGGESTED_USERNAME = "add_account.suggested_username"
|
const val KEY_SUGGESTED_USERNAME = "add_account.suggested_username"
|
||||||
const val KEY_FALLBACK_EMAIL = "add_account.fallback_email"
|
const val KEY_FALLBACK_EMAIL = "add_account.fallback_email"
|
||||||
|
const val KEY_TRUSTED_CERTIFICATES = "add_account.trusted_certificates"
|
||||||
|
const val KEY_CLEARTEXT = "add_account.cleartext"
|
||||||
|
const val KEY_TRUST_ADDRESS = "add_account.trust_address"
|
||||||
|
const val KEY_INHERITED_PINS = "add_account.inherited_pins"
|
||||||
|
|
||||||
|
val TRUST_KEYS = ServerTrustKeys(KEY_TRUSTED_CERTIFICATES, KEY_CLEARTEXT)
|
||||||
|
|
||||||
/** What sends an email to the server field: no CalDAV on its domain, or nothing answering for it. */
|
/** What sends an email to the server field: no CalDAV on its domain, or nothing answering for it. */
|
||||||
val NO_SERVER_FOUND = setOf(
|
val NO_SERVER_FOUND = setOf(
|
||||||
CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER,
|
CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER,
|
||||||
CalDavDiscovery.Outcome.Cause.NO_CALENDAR_SUPPORT,
|
CalDavDiscovery.Outcome.Cause.NO_CALENDAR_SUPPORT,
|
||||||
CalDavDiscovery.Outcome.Cause.UNREACHABLE,
|
CalDavDiscovery.Outcome.Cause.UNREACHABLE,
|
||||||
|
// The domain's own host would not set up TLS: usually its website,
|
||||||
|
// not a CalDAV server, so the server field is the way on.
|
||||||
|
CalDavDiscovery.Outcome.Cause.TLS,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.ui.accounts.add
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.rounded.Badge
|
||||||
|
import androidx.compose.material.icons.rounded.Event
|
||||||
|
import androidx.compose.material.icons.rounded.Fingerprint
|
||||||
|
import androidx.compose.material.icons.rounded.LockOpen
|
||||||
|
import androidx.compose.material.icons.rounded.Verified
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.withStyle
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.agendula.R
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import java.text.DateFormat
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
/** The typed address is plain http: what that exposes, before anything is sent. */
|
||||||
|
@Composable
|
||||||
|
internal fun ConfirmCleartextStep(step: AddAccountStep.ConfirmCleartext) {
|
||||||
|
GroupedRow(
|
||||||
|
title = step.address,
|
||||||
|
position = Position.Alone,
|
||||||
|
leading = { Icon(Icons.Rounded.LockOpen, contentDescription = null) },
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
QuirkNote(text = stringResource(R.string.add_account_cleartext_note))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A certificate the phone does not trust, laid out for comparing against the
|
||||||
|
* server's own. The fingerprint is the part that matters, so it comes first and
|
||||||
|
* can be selected.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun TrustCertificateStep(step: AddAccountStep.TrustCertificate) {
|
||||||
|
val certificate = step.certificate
|
||||||
|
val locale = LocalConfiguration.current.locales[0]
|
||||||
|
val expires = DateFormat.getDateInstance(DateFormat.MEDIUM, locale).format(Date(certificate.notAfterMillis))
|
||||||
|
val rows = listOf(
|
||||||
|
Triple(Icons.Rounded.Fingerprint, R.string.add_account_certificate_fingerprint, certificate.displayFingerprint),
|
||||||
|
Triple(Icons.Rounded.Badge, R.string.add_account_certificate_subject, certificate.subject),
|
||||||
|
Triple(Icons.Rounded.Verified, R.string.add_account_certificate_issuer, certificate.issuer),
|
||||||
|
Triple(Icons.Rounded.Event, R.string.add_account_certificate_expires, expires),
|
||||||
|
)
|
||||||
|
SelectionContainer {
|
||||||
|
Column {
|
||||||
|
rows.forEachIndexed { index, (icon, title, value) ->
|
||||||
|
GroupedRow(
|
||||||
|
title = AnnotatedString(stringResource(title)),
|
||||||
|
summary = if (index == 0) {
|
||||||
|
buildAnnotatedString { withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { append(value) } }
|
||||||
|
} else {
|
||||||
|
AnnotatedString(value)
|
||||||
|
},
|
||||||
|
position = positionOf(index, rows.size),
|
||||||
|
leading = { Icon(icon, contentDescription = null) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
}
|
||||||
@@ -5,11 +5,14 @@
|
|||||||
default trust anchors. That breaks exactly the self-hosting audience this app
|
default trust anchors. That breaks exactly the self-hosting audience this app
|
||||||
is for, so they are added back here.
|
is for, so they are added back here.
|
||||||
|
|
||||||
Cleartext stays off. It has been the default since API 28, and CalDavDiscovery
|
Cleartext is permitted here and gated in code instead, per account: every
|
||||||
refuses a typed http:// address for the same reason: credentials are never
|
client CalDavHttp builds leaves ConnectionSpec.CLEARTEXT out unless the user
|
||||||
sent over an unencrypted connection. Any escape hatch has to be a narrow,
|
opted that account into plain http behind an explicit warning, so OkHttp
|
||||||
warned, per-account opt-in — Play's User Data policy requires modern
|
itself refuses an http:// URL for everyone else. This file cannot express a
|
||||||
cryptography in transit — and this file is not the place for it.
|
per-account choice, only a per-host one, and the hosts are the user's.
|
||||||
|
|
||||||
|
⚠️ That makes CalDavHttp the only gate. A client built any other way would
|
||||||
|
speak http silently, so HttpClientConstructionTest fails the build on one.
|
||||||
|
|
||||||
⚠️ KNOWN TRADE-OFF, and it is the widest form of this. base-config applies to
|
⚠️ KNOWN TRADE-OFF, and it is the widest form of this. base-config applies to
|
||||||
*all* traffic, so any CA in the user store — a corporate MDM profile, a "free
|
*all* traffic, so any CA in the user store — a corporate MDM profile, a "free
|
||||||
@@ -25,7 +28,7 @@
|
|||||||
sit alongside it.
|
sit alongside it.
|
||||||
-->
|
-->
|
||||||
<network-security-config>
|
<network-security-config>
|
||||||
<base-config cleartextTrafficPermitted="false">
|
<base-config cleartextTrafficPermitted="true">
|
||||||
<trust-anchors>
|
<trust-anchors>
|
||||||
<certificates src="system" />
|
<certificates src="system" />
|
||||||
<certificates src="user" />
|
<certificates src="user" />
|
||||||
|
|||||||
@@ -65,10 +65,8 @@
|
|||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTop"
|
||||||
android:windowSoftInputMode="adjustResize">
|
android:windowSoftInputMode="adjustResize">
|
||||||
<intent-filter>
|
<!-- MAIN/LAUNCHER lives on the two aliases below, so the launcher
|
||||||
<action android:name="android.intent.action.MAIN" />
|
name can be switched at runtime (#104). -->
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
|
||||||
</intent-filter>
|
|
||||||
<!-- Shared text becomes a new task's title. -->
|
<!-- Shared text becomes a new task's title. -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.SEND" />
|
<action android:name="android.intent.action.SEND" />
|
||||||
@@ -89,6 +87,32 @@
|
|||||||
<data android:mimeType="text/calendar" />
|
<data android:mimeType="text/calendar" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<activity-alias
|
||||||
|
android:name=".DefaultNameAlias"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:targetActivity=".MainActivity">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity-alias>
|
||||||
|
<activity-alias
|
||||||
|
android:name=".TasksNameAlias"
|
||||||
|
android:enabled="false"
|
||||||
|
android:exported="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name_tasks_alias"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:targetActivity=".MainActivity">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity-alias>
|
||||||
|
|
||||||
<!-- Quick Settings "New task" tile. Only the system QS host can bind it. -->
|
<!-- Quick Settings "New task" tile. Only the system QS host can bind it. -->
|
||||||
<service
|
<service
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import dagger.hilt.components.SingletonComponent
|
|||||||
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
||||||
import de.jeanlucmakiola.agendula.data.di.ChannelRefresher
|
import de.jeanlucmakiola.agendula.data.di.ChannelRefresher
|
||||||
import de.jeanlucmakiola.agendula.data.reminders.ReminderMaintenanceWorker
|
import de.jeanlucmakiola.agendula.data.reminders.ReminderMaintenanceWorker
|
||||||
|
import de.jeanlucmakiola.agendula.data.reminders.ReminderRefresher
|
||||||
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
|
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
|
||||||
import de.jeanlucmakiola.agendula.data.reminders.TaskNotifier
|
import de.jeanlucmakiola.agendula.data.reminders.TaskNotifier
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||||
@@ -65,15 +66,12 @@ class AgendulaApp : Application(), Configuration.Provider {
|
|||||||
// v0.3.x install's tasks, both before anything reads a store.
|
// v0.3.x install's tasks, both before anything reads a store.
|
||||||
val startupGate = entryPoint.startupGate()
|
val startupGate = entryPoint.startupGate()
|
||||||
val scope = entryPoint.applicationScope()
|
val scope = entryPoint.applicationScope()
|
||||||
// An alarm is armed off whichever store was active when it was scheduled,
|
// A switch rebuilds the alarms through ReminderRefresher, which hears it
|
||||||
// so a switch has to rebuild the set. Armed only once startup's own
|
// as a store change. Channels only once startup's own null -> stored
|
||||||
// null -> stored transition is past, which the launch sync below covers.
|
// transition is past, which the launch sync below covers.
|
||||||
val started = AtomicBoolean(false)
|
val started = AtomicBoolean(false)
|
||||||
entryPoint.providerResolver().onModeChanged {
|
entryPoint.providerResolver().onModeChanged {
|
||||||
if (started.get()) {
|
if (started.get()) runCatching { entryPoint.taskNotifier().refreshChannel() }
|
||||||
scope.launch { runCatching { scheduler.sync() } }
|
|
||||||
runCatching { entryPoint.taskNotifier().refreshChannel() }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
startupGate.start()
|
startupGate.start()
|
||||||
refreshNotificationChannels()
|
refreshNotificationChannels()
|
||||||
@@ -88,6 +86,7 @@ class AgendulaApp : Application(), Configuration.Provider {
|
|||||||
scheduler.sync()
|
scheduler.sync()
|
||||||
}
|
}
|
||||||
runCatching { entryPoint.taskWidgetUpdater().start() }
|
runCatching { entryPoint.taskWidgetUpdater().start() }
|
||||||
|
runCatching { entryPoint.reminderRefresher().start() }
|
||||||
runCatching { entryPoint.providerSnapshotSource().start() }
|
runCatching { entryPoint.providerSnapshotSource().start() }
|
||||||
runCatching { ReminderMaintenanceWorker.schedule(this@AgendulaApp) }
|
runCatching { ReminderMaintenanceWorker.schedule(this@AgendulaApp) }
|
||||||
}
|
}
|
||||||
@@ -124,6 +123,7 @@ class AgendulaApp : Application(), Configuration.Provider {
|
|||||||
fun taskNotifier(): TaskNotifier
|
fun taskNotifier(): TaskNotifier
|
||||||
fun channelRefreshers(): Set<@JvmSuppressWildcards ChannelRefresher>
|
fun channelRefreshers(): Set<@JvmSuppressWildcards ChannelRefresher>
|
||||||
fun taskWidgetUpdater(): TaskWidgetUpdater
|
fun taskWidgetUpdater(): TaskWidgetUpdater
|
||||||
|
fun reminderRefresher(): ReminderRefresher
|
||||||
fun providerSnapshotSource(): ProviderSnapshotSource
|
fun providerSnapshotSource(): ProviderSnapshotSource
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16,6 +16,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
|
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.is24Hour
|
import de.jeanlucmakiola.agendula.data.prefs.is24Hour
|
||||||
import de.jeanlucmakiola.agendula.ui.common.LocalFirstDayOfWeek
|
import de.jeanlucmakiola.agendula.ui.common.LocalFirstDayOfWeek
|
||||||
import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat
|
||||||
@@ -28,15 +29,17 @@ import de.jeanlucmakiola.agendula.data.di.LaunchHook
|
|||||||
import de.jeanlucmakiola.agendula.domain.SmartList
|
import de.jeanlucmakiola.agendula.domain.SmartList
|
||||||
import de.jeanlucmakiola.agendula.ui.imports.importIntentUri
|
import de.jeanlucmakiola.agendula.ui.imports.importIntentUri
|
||||||
import de.jeanlucmakiola.agendula.ui.navigation.AppShortcuts
|
import de.jeanlucmakiola.agendula.ui.navigation.AppShortcuts
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
|
||||||
import de.jeanlucmakiola.agendula.ui.RootScreen
|
import de.jeanlucmakiola.agendula.ui.RootScreen
|
||||||
import de.jeanlucmakiola.agendula.ui.crash.CrashReportActivity
|
import de.jeanlucmakiola.agendula.ui.crash.CrashReportActivity
|
||||||
import de.jeanlucmakiola.agendula.ui.navigation.NavRequest
|
import de.jeanlucmakiola.agendula.ui.navigation.NavRequest
|
||||||
import de.jeanlucmakiola.agendula.ui.settings.SettingsViewModel
|
import de.jeanlucmakiola.agendula.ui.settings.SettingsViewModel
|
||||||
import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme
|
import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme
|
||||||
|
import de.jeanlucmakiola.agendula.ui.theme.SystemTheme
|
||||||
import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
||||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||||
import de.jeanlucmakiola.floret.crash.submitCrashReport
|
import de.jeanlucmakiola.floret.crash.submitCrashReport
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -50,6 +53,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
@Inject lateinit var launchHooks: Set<@JvmSuppressWildcards LaunchHook>
|
@Inject lateinit var launchHooks: Set<@JvmSuppressWildcards LaunchHook>
|
||||||
|
|
||||||
|
@Inject lateinit var settingsPrefs: SettingsPrefs
|
||||||
|
|
||||||
// A captured crash report awaiting the user's decision, surfaced as a dialog
|
// A captured crash report awaiting the user's decision, surfaced as a dialog
|
||||||
// over the app on the next launch (the single-crash path). A startup
|
// over the app on the next launch (the single-crash path). A startup
|
||||||
// crash-loop is handled out of band, before setContent — see below.
|
// crash-loop is handled out of band, before setContent — see below.
|
||||||
@@ -73,6 +78,13 @@ class MainActivity : ComponentActivity() {
|
|||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
AppShortcuts.publish(this)
|
AppShortcuts.publish(this)
|
||||||
|
|
||||||
|
// The stored setting, never the view model's placeholder: a night mode
|
||||||
|
// that flips back and forth would recreate the activity each time.
|
||||||
|
lifecycleScope.launch {
|
||||||
|
settingsPrefs.settings.map { it.themeMode to it.dynamicColor }.distinctUntilChanged()
|
||||||
|
.collect { (mode, dynamicColor) -> SystemTheme.apply(this@MainActivity, mode, dynamicColor) }
|
||||||
|
}
|
||||||
|
|
||||||
// Only on a fresh launch: after a rotation or process restore the intent
|
// Only on a fresh launch: after a rotation or process restore the intent
|
||||||
// is the same old one and the back stack already holds its destination.
|
// is the same old one and the back stack already holds its destination.
|
||||||
val fromHistory = intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0
|
val fromHistory = intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0
|
||||||
@@ -88,15 +100,15 @@ class MainActivity : ComponentActivity() {
|
|||||||
setContent {
|
setContent {
|
||||||
val settingsViewModel: SettingsViewModel = hiltViewModel()
|
val settingsViewModel: SettingsViewModel = hiltViewModel()
|
||||||
val ui by settingsViewModel.state.collectAsStateWithLifecycle()
|
val ui by settingsViewModel.state.collectAsStateWithLifecycle()
|
||||||
val darkTheme = when (ui.settings.themeMode) {
|
val darkTheme = ui.settings.themeMode.isDark(isSystemInDarkTheme())
|
||||||
ThemeMode.SYSTEM -> isSystemInDarkTheme()
|
|
||||||
ThemeMode.LIGHT -> false
|
|
||||||
ThemeMode.DARK -> true
|
|
||||||
}
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val use24Hour = ui.settings.timeFormat.is24Hour(DateFormat.is24HourFormat(context))
|
val use24Hour = ui.settings.timeFormat.is24Hour(DateFormat.is24HourFormat(context))
|
||||||
val firstDayOfWeek = ui.settings.weekStart ?: localeFirstDayOfWeek()
|
val firstDayOfWeek = ui.settings.weekStart ?: localeFirstDayOfWeek()
|
||||||
AgendulaTheme(darkTheme = darkTheme, dynamicColor = ui.settings.dynamicColor) {
|
AgendulaTheme(
|
||||||
|
darkTheme = darkTheme,
|
||||||
|
dynamicColor = ui.settings.dynamicColor,
|
||||||
|
pureBlack = ui.settings.themeMode.isPureBlack,
|
||||||
|
) {
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalUse24HourFormat provides use24Hour,
|
LocalUse24HourFormat provides use24Hour,
|
||||||
LocalFirstDayOfWeek provides firstDayOfWeek,
|
LocalFirstDayOfWeek provides firstDayOfWeek,
|
||||||
@@ -105,6 +117,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
navRequest = navRequest,
|
navRequest = navRequest,
|
||||||
onNavRequestConsumed = { navRequest = null },
|
onNavRequestConsumed = { navRequest = null },
|
||||||
|
onReturnHome = ::finish,
|
||||||
)
|
)
|
||||||
pendingCrashReport?.let { report ->
|
pendingCrashReport?.let { report ->
|
||||||
CrashReportDialog(
|
CrashReportDialog(
|
||||||
@@ -153,7 +166,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
private const val EXTRA_FILTER_ID = "de.jeanlucmakiola.agendula.extra.FILTER_ID"
|
private const val EXTRA_FILTER_ID = "de.jeanlucmakiola.agendula.extra.FILTER_ID"
|
||||||
private const val EXTRA_SMART_LIST = "de.jeanlucmakiola.agendula.extra.SMART_LIST"
|
private const val EXTRA_SMART_LIST = "de.jeanlucmakiola.agendula.extra.SMART_LIST"
|
||||||
private const val EXTRA_LIST_ID = "de.jeanlucmakiola.agendula.extra.LIST_ID"
|
private const val EXTRA_LIST_ID = "de.jeanlucmakiola.agendula.extra.LIST_ID"
|
||||||
|
private const val EXTRA_RETURN_HOME = "de.jeanlucmakiola.agendula.extra.RETURN_HOME"
|
||||||
private const val EXTRA_PRESET_LIST_ID = "de.jeanlucmakiola.agendula.extra.PRESET_LIST_ID"
|
private const val EXTRA_PRESET_LIST_ID = "de.jeanlucmakiola.agendula.extra.PRESET_LIST_ID"
|
||||||
|
private const val EXTRA_DUE_TODAY = "de.jeanlucmakiola.agendula.extra.DUE_TODAY"
|
||||||
private const val SHARED_TITLE_LIMIT = 500
|
private const val SHARED_TITLE_LIMIT = 500
|
||||||
private const val NO_OCCURRENCE = -1L
|
private const val NO_OCCURRENCE = -1L
|
||||||
|
|
||||||
@@ -161,10 +176,16 @@ class MainActivity : ComponentActivity() {
|
|||||||
* Opens a task's detail (reminder taps). [occurrenceStart] (epoch millis)
|
* Opens a task's detail (reminder taps). [occurrenceStart] (epoch millis)
|
||||||
* picks the occurrence of a recurring task.
|
* picks the occurrence of a recurring task.
|
||||||
*/
|
*/
|
||||||
fun taskIntent(context: Context, taskId: Long, occurrenceStart: Long? = null): Intent =
|
fun taskIntent(
|
||||||
|
context: Context,
|
||||||
|
taskId: Long,
|
||||||
|
occurrenceStart: Long? = null,
|
||||||
|
returnHome: Boolean = false,
|
||||||
|
): Intent =
|
||||||
Intent(context, MainActivity::class.java).apply {
|
Intent(context, MainActivity::class.java).apply {
|
||||||
putExtra(EXTRA_TASK_ID, taskId)
|
putExtra(EXTRA_TASK_ID, taskId)
|
||||||
putExtra(EXTRA_OCCURRENCE_START, occurrenceStart ?: NO_OCCURRENCE)
|
putExtra(EXTRA_OCCURRENCE_START, occurrenceStart ?: NO_OCCURRENCE)
|
||||||
|
putExtra(EXTRA_RETURN_HOME, returnHome)
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,13 +197,17 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The launcher shortcut's, Quick Settings tile's and a widget's "New
|
* The launcher shortcut's, Quick Settings tile's and a widget's "New
|
||||||
* task". [listId] preset the list when it names exactly one; the data
|
* task". [listId] preset the list when it names exactly one, [dueToday]
|
||||||
* URI keeps each widget's PendingIntent apart from the others.
|
* the due date for a Today widget; the data URI keeps each widget's
|
||||||
|
* PendingIntent apart from the others.
|
||||||
*/
|
*/
|
||||||
fun newTaskIntent(context: Context, listId: Long? = null): Intent =
|
fun newTaskIntent(context: Context, listId: Long? = null, dueToday: Boolean = false): Intent {
|
||||||
Intent(ACTION_NEW_TASK, listId?.let { "agendula://newtask/$it".toUri() }, context, MainActivity::class.java)
|
val path = listOfNotNull(listId?.toString(), "today".takeIf { dueToday }).joinToString("/")
|
||||||
|
return Intent(ACTION_NEW_TASK, path.ifEmpty { null }?.let { "agendula://newtask/$it".toUri() }, context, MainActivity::class.java)
|
||||||
.putExtra(EXTRA_PRESET_LIST_ID, listId ?: -1L)
|
.putExtra(EXTRA_PRESET_LIST_ID, listId ?: -1L)
|
||||||
|
.putExtra(EXTRA_DUE_TODAY, dueToday)
|
||||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
|
||||||
fun todayIntent(context: Context): Intent =
|
fun todayIntent(context: Context): Intent =
|
||||||
Intent(ACTION_TODAY, null, context, MainActivity::class.java)
|
Intent(ACTION_TODAY, null, context, MainActivity::class.java)
|
||||||
@@ -216,6 +241,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
when (intent.action) {
|
when (intent.action) {
|
||||||
ACTION_NEW_TASK -> return NavRequest.NewTask(
|
ACTION_NEW_TASK -> return NavRequest.NewTask(
|
||||||
listId = intent.getLongExtra(EXTRA_PRESET_LIST_ID, -1L).takeIf { it > 0L },
|
listId = intent.getLongExtra(EXTRA_PRESET_LIST_ID, -1L).takeIf { it > 0L },
|
||||||
|
dueToday = intent.getBooleanExtra(EXTRA_DUE_TODAY, false),
|
||||||
|
returnHome = true,
|
||||||
)
|
)
|
||||||
ACTION_TODAY -> return NavRequest.OpenSmart(SmartList.TODAY)
|
ACTION_TODAY -> return NavRequest.OpenSmart(SmartList.TODAY)
|
||||||
ACTION_OPEN_SMART -> intent.getStringExtra(EXTRA_SMART_LIST)
|
ACTION_OPEN_SMART -> intent.getStringExtra(EXTRA_SMART_LIST)
|
||||||
@@ -230,12 +257,12 @@ class MainActivity : ComponentActivity() {
|
|||||||
if (intent.action == Intent.ACTION_SEND && intent.type?.startsWith("text/plain") == true) {
|
if (intent.action == Intent.ACTION_SEND && intent.type?.startsWith("text/plain") == true) {
|
||||||
val shared = intent.getStringExtra(Intent.EXTRA_SUBJECT)?.takeIf { it.isNotBlank() }
|
val shared = intent.getStringExtra(Intent.EXTRA_SUBJECT)?.takeIf { it.isNotBlank() }
|
||||||
?: intent.getStringExtra(Intent.EXTRA_TEXT)
|
?: intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||||
return NavRequest.NewTask(shared?.trim()?.take(SHARED_TITLE_LIMIT))
|
return NavRequest.NewTask(shared?.trim()?.take(SHARED_TITLE_LIMIT), returnHome = true)
|
||||||
}
|
}
|
||||||
val taskId = intent.getLongExtra(EXTRA_TASK_ID, -1L).takeIf { it > 0L } ?: return null
|
val taskId = intent.getLongExtra(EXTRA_TASK_ID, -1L).takeIf { it > 0L } ?: return null
|
||||||
val occurrence = intent.getLongExtra(EXTRA_OCCURRENCE_START, NO_OCCURRENCE)
|
val occurrence = intent.getLongExtra(EXTRA_OCCURRENCE_START, NO_OCCURRENCE)
|
||||||
.takeIf { it != NO_OCCURRENCE }
|
.takeIf { it != NO_OCCURRENCE }
|
||||||
return NavRequest.OpenTask(taskId, occurrence)
|
return NavRequest.OpenTask(taskId, occurrence, intent.getBooleanExtra(EXTRA_RETURN_HOME, false))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.appname
|
||||||
|
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.agendula.ui.navigation.AppShortcuts
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/** The label the launcher shows for the app (#104). */
|
||||||
|
enum class LauncherName { AGENDULA, TASKS }
|
||||||
|
|
||||||
|
/** The two launcher aliases declared in the manifest. */
|
||||||
|
enum class LauncherAlias { DEFAULT, TASKS }
|
||||||
|
|
||||||
|
/** One component-enable change in an [aliasWritePlan]. */
|
||||||
|
data class AliasStateChange(val alias: LauncherAlias, val enabled: Boolean)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the `TasksNameAlias` enabled state as a [LauncherName]. Only an explicit
|
||||||
|
* enable means "Tasks"; never toggled and disabled both mean the manifest default.
|
||||||
|
*/
|
||||||
|
fun launcherNameFor(tasksAliasState: Int): LauncherName =
|
||||||
|
if (tasksAliasState == PackageManager.COMPONENT_ENABLED_STATE_ENABLED) {
|
||||||
|
LauncherName.TASKS
|
||||||
|
} else {
|
||||||
|
LauncherName.AGENDULA
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The steps to switch to [target]: enable the target alias first, then disable
|
||||||
|
* the other, so the launcher can briefly see two entries but never none.
|
||||||
|
*/
|
||||||
|
fun aliasWritePlan(target: LauncherName): List<AliasStateChange> = when (target) {
|
||||||
|
LauncherName.TASKS -> listOf(
|
||||||
|
AliasStateChange(LauncherAlias.TASKS, enabled = true),
|
||||||
|
AliasStateChange(LauncherAlias.DEFAULT, enabled = false),
|
||||||
|
)
|
||||||
|
LauncherName.AGENDULA -> listOf(
|
||||||
|
AliasStateChange(LauncherAlias.DEFAULT, enabled = true),
|
||||||
|
AliasStateChange(LauncherAlias.TASKS, enabled = false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switches the launcher label by enabling one of the two `<activity-alias>`
|
||||||
|
* components. The component state is the only source of truth; nothing is
|
||||||
|
* stored in prefs.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class LauncherNameManager @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
private val packageManager: PackageManager get() = context.packageManager
|
||||||
|
|
||||||
|
// The package is the (suffixed) applicationId, the class is namespace-qualified.
|
||||||
|
private fun component(alias: LauncherAlias): ComponentName {
|
||||||
|
val simpleName = when (alias) {
|
||||||
|
LauncherAlias.DEFAULT -> "DefaultNameAlias"
|
||||||
|
LauncherAlias.TASKS -> "TasksNameAlias"
|
||||||
|
}
|
||||||
|
return ComponentName(context.packageName, "$NAMESPACE.$simpleName")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun current(): LauncherName =
|
||||||
|
launcherNameFor(packageManager.getComponentEnabledSetting(component(LauncherAlias.TASKS)))
|
||||||
|
|
||||||
|
fun set(name: LauncherName) {
|
||||||
|
for (change in aliasWritePlan(name)) {
|
||||||
|
val state = if (change.enabled) {
|
||||||
|
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
||||||
|
} else {
|
||||||
|
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
|
||||||
|
}
|
||||||
|
packageManager.setComponentEnabledSetting(component(change.alias), state, PackageManager.DONT_KILL_APP)
|
||||||
|
}
|
||||||
|
// Dynamic shortcuts hang off the launcher activity, which just changed.
|
||||||
|
AppShortcuts.publish(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** `namespace` in app/build.gradle.kts, not the suffixed applicationId. */
|
||||||
|
const val NAMESPACE = "de.jeanlucmakiola.agendula"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,7 +89,7 @@ object DataProvideModule {
|
|||||||
// backed-up `.db` does not. The backup rules carry all three files and
|
// backed-up `.db` does not. The backup rules carry all three files and
|
||||||
// the app checkpoints on ON_STOP.
|
// the app checkpoints on ON_STOP.
|
||||||
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
|
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
|
||||||
.addMigrations(TasksDatabase.MIGRATION_1_2, TasksDatabase.MIGRATION_2_3)
|
.addMigrations(TasksDatabase.MIGRATION_1_2, TasksDatabase.MIGRATION_2_3, TasksDatabase.MIGRATION_3_4, TasksDatabase.MIGRATION_4_5)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import de.jeanlucmakiola.agendula.domain.Task
|
|||||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskSection
|
||||||
import de.jeanlucmakiola.agendula.domain.asTaskReminder
|
import de.jeanlucmakiola.agendula.domain.asTaskReminder
|
||||||
import de.jeanlucmakiola.agendula.domain.asReminderLead
|
import de.jeanlucmakiola.agendula.domain.asReminderLead
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskSortOrder
|
import de.jeanlucmakiola.agendula.domain.TaskSortOrder
|
||||||
@@ -31,7 +32,18 @@ import kotlinx.coroutines.flow.map
|
|||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
enum class ThemeMode { SYSTEM, LIGHT, DARK }
|
/** [BLACK] is dark with pure-black surfaces, for AMOLED screens. */
|
||||||
|
enum class ThemeMode {
|
||||||
|
SYSTEM, LIGHT, DARK, BLACK;
|
||||||
|
|
||||||
|
fun isDark(systemDark: Boolean): Boolean = when (this) {
|
||||||
|
SYSTEM -> systemDark
|
||||||
|
LIGHT -> false
|
||||||
|
DARK, BLACK -> true
|
||||||
|
}
|
||||||
|
|
||||||
|
val isPureBlack: Boolean get() = this == BLACK
|
||||||
|
}
|
||||||
|
|
||||||
/** Clock convention: AUTO follows the device's 24-hour switch. */
|
/** Clock convention: AUTO follows the device's 24-hour switch. */
|
||||||
enum class TimeFormatPref { AUTO, TWELVE_HOUR, TWENTY_FOUR_HOUR }
|
enum class TimeFormatPref { AUTO, TWELVE_HOUR, TWENTY_FOUR_HOUR }
|
||||||
@@ -43,6 +55,7 @@ fun TimeFormatPref.is24Hour(systemIs24Hour: Boolean): Boolean = when (this) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const val DEFAULT_SNOOZE_MINUTES = 10
|
const val DEFAULT_SNOOZE_MINUTES = 10
|
||||||
|
/** Done starts folded away, to keep the open tasks in focus. */ val DEFAULT_COLLAPSED_SECTIONS: Set<TaskSection> = setOf(TaskSection.COMPLETED)
|
||||||
|
|
||||||
/** The snooze lengths Settings offers. */
|
/** The snooze lengths Settings offers. */
|
||||||
val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60)
|
val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60)
|
||||||
@@ -103,6 +116,8 @@ data class Settings(
|
|||||||
val hiddenFromSmartLists: Set<Long> = emptySet(),
|
val hiddenFromSmartLists: Set<Long> = emptySet(),
|
||||||
/** Pre-fill a new task's start with today's date. */
|
/** Pre-fill a new task's start with today's date. */
|
||||||
val defaultStartToday: Boolean = false,
|
val defaultStartToday: Boolean = false,
|
||||||
|
/** Task-list sections folded away; Done starts folded. */
|
||||||
|
val collapsedSections: Set<TaskSection> = DEFAULT_COLLAPSED_SECTIONS,
|
||||||
) {
|
) {
|
||||||
/** [tasks] as [filter] shows them: a smart list drops the lists kept out of it. */
|
/** [tasks] as [filter] shows them: a smart list drops the lists kept out of it. */
|
||||||
fun visibleIn(filter: TaskFilter, tasks: List<Task>): List<Task> =
|
fun visibleIn(filter: TaskFilter, tasks: List<Task>): List<Task> =
|
||||||
@@ -168,9 +183,18 @@ class SettingsPrefs @Inject constructor(
|
|||||||
.toSet()
|
.toSet()
|
||||||
},
|
},
|
||||||
defaultStartToday = p[DEFAULT_START_TODAY] ?: false,
|
defaultStartToday = p[DEFAULT_START_TODAY] ?: false,
|
||||||
|
collapsedSections = p[COLLAPSED_SECTIONS]
|
||||||
|
?.mapNotNull { name -> runCatching { TaskSection.valueOf(name) }.getOrNull() }
|
||||||
|
?.toSet()
|
||||||
|
?: DEFAULT_COLLAPSED_SECTIONS,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun setSectionCollapsed(section: TaskSection, collapsed: Boolean) = dataStore.edit { p ->
|
||||||
|
val current = p[COLLAPSED_SECTIONS] ?: DEFAULT_COLLAPSED_SECTIONS.mapTo(HashSet()) { it.name }
|
||||||
|
p[COLLAPSED_SECTIONS] = if (collapsed) current + section.name else current - section.name
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun setSortOrder(order: TaskSortOrder) = dataStore.edit { it[SORT_ORDER] = order.name }
|
suspend fun setSortOrder(order: TaskSortOrder) = dataStore.edit { it[SORT_ORDER] = order.name }
|
||||||
|
|
||||||
suspend fun setSnoozeMinutes(minutes: Int) = dataStore.edit { it[SNOOZE_MINUTES] = minutes.coerceAtLeast(1) }
|
suspend fun setSnoozeMinutes(minutes: Int) = dataStore.edit { it[SNOOZE_MINUTES] = minutes.coerceAtLeast(1) }
|
||||||
@@ -328,6 +352,7 @@ class SettingsPrefs @Inject constructor(
|
|||||||
val PUSH_ENABLED = booleanPreferencesKey("push_enabled")
|
val PUSH_ENABLED = booleanPreferencesKey("push_enabled")
|
||||||
val SMART_LIST_HIDDEN = stringSetPreferencesKey("smart_list_hidden")
|
val SMART_LIST_HIDDEN = stringSetPreferencesKey("smart_list_hidden")
|
||||||
val DEFAULT_START_TODAY = booleanPreferencesKey("default_start_today")
|
val DEFAULT_START_TODAY = booleanPreferencesKey("default_start_today")
|
||||||
|
val COLLAPSED_SECTIONS = stringSetPreferencesKey("collapsed_sections")
|
||||||
val SAVED_FILTERS = stringPreferencesKey("saved_filters")
|
val SAVED_FILTERS = stringPreferencesKey("saved_filters")
|
||||||
val SAVED_FILTER_NEXT_ID = longPreferencesKey("saved_filter_next_id")
|
val SAVED_FILTER_NEXT_ID = longPreferencesKey("saved_filter_next_id")
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-12
@@ -5,6 +5,7 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import de.jeanlucmakiola.agendula.data.prefs.Settings
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
|
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||||
@@ -15,6 +16,7 @@ import kotlinx.coroutines.SupervisorJob
|
|||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fires when a task's reminder alarm goes off. Re-reads the task (it may have
|
* Fires when a task's reminder alarm goes off. Re-reads the task (it may have
|
||||||
@@ -44,8 +46,7 @@ class DueReminderReceiver : BroadcastReceiver() {
|
|||||||
}
|
}
|
||||||
val settings = settingsPrefs.settings.first()
|
val settings = settingsPrefs.settings.first()
|
||||||
if (!settings.remindersEnabled) return@launch
|
if (!settings.remindersEnabled) return@launch
|
||||||
val task = runCatching { dataSource.occurrence(taskId, occurrence) }.getOrNull()
|
runCatching { notifier.postIfOpen(dataSource, taskId, occurrence, settings) }
|
||||||
if (task != null && !task.isClosed) notifier.postDue(task, settings)
|
|
||||||
} finally {
|
} finally {
|
||||||
pending.finish()
|
pending.finish()
|
||||||
}
|
}
|
||||||
@@ -74,17 +75,51 @@ class DueReminderReceiver : BroadcastReceiver() {
|
|||||||
/**
|
/**
|
||||||
* The occurrence of [taskId] anchored at [occurrenceStart] (epoch millis), or the
|
* The occurrence of [taskId] anchored at [occurrenceStart] (epoch millis), or the
|
||||||
* task's current one when there is no anchor or it can no longer be found.
|
* task's current one when there is no anchor or it can no longer be found.
|
||||||
* [TasksDataSource.task] alone resolves a series to whichever occurrence is
|
*
|
||||||
* current, which is not necessarily the one a reminder was armed for.
|
* ⚠️ A ticked or edited occurrence is a row of its own with its own id, so
|
||||||
|
* matching list rows on [taskId] misses it; [TasksDataSource.task] with the
|
||||||
|
* anchor finds it by start. The list scan is for a provider whose exception
|
||||||
|
* rows that lookup does not reach.
|
||||||
*/
|
*/
|
||||||
internal fun TasksDataSource.occurrence(taskId: Long, occurrenceStart: Long): Task? {
|
internal fun TasksDataSource.occurrence(taskId: Long, occurrenceStart: Long): Task? {
|
||||||
val current = task(taskId) ?: return null
|
val anchor = occurrenceStart.takeIf { it != ScheduledReminder.NO_OCCURRENCE }?.let(Instant::fromEpochMilliseconds)
|
||||||
if (occurrenceStart == ScheduledReminder.NO_OCCURRENCE ||
|
val found = task(taskId, anchor) ?: return null
|
||||||
current.occurrenceStart?.toEpochMilliseconds() == occurrenceStart
|
if (anchor == null || found.occurrenceStart == anchor) return found
|
||||||
) {
|
return tasks(TaskQuery(listId = found.listId, includeCompleted = true))
|
||||||
return current
|
.firstOrNull { (it.taskId == taskId || it.seriesId == taskId) && it.occurrenceStart == anchor }
|
||||||
|
?: found
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a reminder posted for [occurrenceStart] is done with, given [current]
|
||||||
|
* as [occurrence] resolved it.
|
||||||
|
*
|
||||||
|
* - No task: gone, but only when [missingMeansGone] — a provider that fails a
|
||||||
|
* read hands back nothing rather than throwing.
|
||||||
|
* - [occurrenceStart] null: posted before reminders recorded their occurrence,
|
||||||
|
* so only a task that does not recur can be judged.
|
||||||
|
* - A lookup that fell back to another occurrence says nothing about the
|
||||||
|
* reminded one, so it stays.
|
||||||
|
*/
|
||||||
|
internal fun reminderIsStale(occurrenceStart: Long?, current: Task?, missingMeansGone: Boolean): Boolean {
|
||||||
|
if (current == null) return missingMeansGone
|
||||||
|
val same = when (occurrenceStart) {
|
||||||
|
null -> current.occurrenceStart == null
|
||||||
|
ScheduledReminder.NO_OCCURRENCE -> true
|
||||||
|
else -> current.occurrenceStart?.toEpochMilliseconds() == occurrenceStart
|
||||||
}
|
}
|
||||||
return tasks(TaskQuery(listId = current.listId, includeCompleted = true))
|
return same && current.isClosed
|
||||||
.firstOrNull { it.taskId == taskId && it.occurrenceStart?.toEpochMilliseconds() == occurrenceStart }
|
}
|
||||||
?: current
|
|
||||||
|
/**
|
||||||
|
* Posts the reminder for [taskId]'s occurrence if it is still open, then checks
|
||||||
|
* once more: a completion landing between the read and the post may already
|
||||||
|
* have had its withdrawal pass, which ran before there was anything to withdraw.
|
||||||
|
*/
|
||||||
|
internal fun TaskNotifier.postIfOpen(dataSource: TasksDataSource, taskId: Long, occurrenceStart: Long, settings: Settings) {
|
||||||
|
val task = dataSource.occurrence(taskId, occurrenceStart) ?: return
|
||||||
|
if (task.isClosed) return
|
||||||
|
postDue(task, settings)
|
||||||
|
val now = runCatching { dataSource.occurrence(taskId, occurrenceStart) }.getOrElse { return }
|
||||||
|
if (now == null || now.isClosed) cancel(taskId)
|
||||||
}
|
}
|
||||||
+1
-4
@@ -32,7 +32,6 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
|||||||
|
|
||||||
@Inject lateinit var notifier: TaskNotifier
|
@Inject lateinit var notifier: TaskNotifier
|
||||||
@Inject lateinit var snoozeScheduler: ReminderSnoozeScheduler
|
@Inject lateinit var snoozeScheduler: ReminderSnoozeScheduler
|
||||||
@Inject lateinit var reminderScheduler: ReminderScheduler
|
|
||||||
@Inject lateinit var repository: TasksRepository
|
@Inject lateinit var repository: TasksRepository
|
||||||
@Inject lateinit var dataSource: TasksDataSource
|
@Inject lateinit var dataSource: TasksDataSource
|
||||||
@Inject lateinit var settingsPrefs: SettingsPrefs
|
@Inject lateinit var settingsPrefs: SettingsPrefs
|
||||||
@@ -54,7 +53,6 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
|||||||
val anchor = occurrence.takeIf { it != ScheduledReminder.NO_OCCURRENCE }
|
val anchor = occurrence.takeIf { it != ScheduledReminder.NO_OCCURRENCE }
|
||||||
?.let(Instant::fromEpochMilliseconds)
|
?.let(Instant::fromEpochMilliseconds)
|
||||||
repository.setCompleted(taskId, anchor, completed = true)
|
repository.setCompleted(taskId, anchor, completed = true)
|
||||||
reminderScheduler.sync()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ACTION_SNOOZE -> runCatching {
|
ACTION_SNOOZE -> runCatching {
|
||||||
@@ -67,8 +65,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
|||||||
val settings = settingsPrefs.settings.first()
|
val settings = settingsPrefs.settings.first()
|
||||||
if (!settings.remindersEnabled) return@runCatching
|
if (!settings.remindersEnabled) return@runCatching
|
||||||
startupGate.awaitReady()
|
startupGate.awaitReady()
|
||||||
val task = dataSource.occurrence(taskId, occurrence)
|
notifier.postIfOpen(dataSource, taskId, occurrence, settings)
|
||||||
if (task != null && !task.isClosed) notifier.postDue(task, settings)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ internal data class ReminderDiff(
|
|||||||
fired: Set<ScheduledReminder> = emptySet(),
|
fired: Set<ScheduledReminder> = emptySet(),
|
||||||
isArmed: (ScheduledReminder) -> Boolean,
|
isArmed: (ScheduledReminder) -> Boolean,
|
||||||
): ReminderDiff {
|
): ReminderDiff {
|
||||||
val live = previous.filterTo(HashSet()) { it in fired || isArmed(it) }
|
// A fired entry counts even once the store dropped it, so a task
|
||||||
|
// reopened within the grace does not ring again.
|
||||||
|
val live = previous.filterTo(HashSet()) { it in fired || isArmed(it) } + fired
|
||||||
return ReminderDiff(
|
return ReminderDiff(
|
||||||
cancel = live - desired,
|
cancel = live - desired,
|
||||||
arm = desired - live,
|
arm = desired - live,
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.reminders
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.StoreChangeDebouncer
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps reminders in step with the store: any change to tasks, lists or alarms —
|
||||||
|
* a tick in the list or on a widget, a sync pulling in a completion from another
|
||||||
|
* device, an edit, a store switch — runs one debounced [ReminderScheduler.sync].
|
||||||
|
* Started once from `AgendulaApp`, after the startup gate, so it observes the
|
||||||
|
* right store.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class ReminderRefresher @Inject constructor(
|
||||||
|
dataSource: TasksDataSource,
|
||||||
|
scheduler: ReminderScheduler,
|
||||||
|
@ApplicationScope scope: CoroutineScope,
|
||||||
|
) {
|
||||||
|
private val changes = StoreChangeDebouncer(scope, DEBOUNCE_MILLIS, dataSource::registerTaskObserver) { scheduler.sync() }
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
changes.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-sync soon, folded into any pass a store change already asked for. */
|
||||||
|
fun requestRefresh() = changes.request()
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val DEBOUNCE_MILLIS = 1_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
|||||||
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
|
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -23,8 +24,9 @@ import javax.inject.Singleton
|
|||||||
* The self-scheduled due-reminder engine. Nothing else delivers task reminders —
|
* The self-scheduled due-reminder engine. Nothing else delivers task reminders —
|
||||||
* not the platform, not a tasks provider — so Agendula reads upcoming due tasks
|
* not the platform, not a tasks provider — so Agendula reads upcoming due tasks
|
||||||
* and arms one exact [AlarmManager] alarm per reminder, within a rolling window. Re-run
|
* and arms one exact [AlarmManager] alarm per reminder, within a rolling window. Re-run
|
||||||
* on app start, on boot, on a store switch, and on an external provider change;
|
* on app start, on boot, on a store switch and on every change to the store (see
|
||||||
* it diffs against [ScheduledReminderStore] so only changed alarms move.
|
* [ReminderRefresher]); it diffs against [ScheduledReminderStore] so only changed
|
||||||
|
* alarms move, and takes down posted reminders whose task is done with.
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class ReminderScheduler @Inject constructor(
|
class ReminderScheduler @Inject constructor(
|
||||||
@@ -34,6 +36,7 @@ class ReminderScheduler @Inject constructor(
|
|||||||
private val store: ScheduledReminderStore,
|
private val store: ScheduledReminderStore,
|
||||||
private val providerResolver: ProviderResolver,
|
private val providerResolver: ProviderResolver,
|
||||||
private val snoozeScheduler: ReminderSnoozeScheduler,
|
private val snoozeScheduler: ReminderSnoozeScheduler,
|
||||||
|
private val notifier: TaskNotifier,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) {
|
) {
|
||||||
private val syncLock = Mutex()
|
private val syncLock = Mutex()
|
||||||
@@ -65,19 +68,23 @@ class ReminderScheduler @Inject constructor(
|
|||||||
// Gate on whether the store is readable, not on whether a provider
|
// Gate on whether the store is readable, not on whether a provider
|
||||||
// resolves: our own store deliberately resolves to no provider, so the
|
// resolves: our own store deliberately resolves to no provider, so the
|
||||||
// latter clears every reminder in the default mode.
|
// latter clears every reminder in the default mode.
|
||||||
if (!settings.remindersEnabled || !providerResolver.canReadStore()) {
|
val readable = providerResolver.canReadStore()
|
||||||
|
if (!settings.remindersEnabled || !readable) {
|
||||||
clearAll()
|
clearAll()
|
||||||
|
// Switched off, a reminder already up still goes when its task does.
|
||||||
|
if (readable) withdrawStale()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) }
|
val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) }
|
||||||
.getOrElse { return }
|
.getOrElse { return }
|
||||||
// Per-task leads. One query for all of them.
|
// Per-task leads. One query for all of them.
|
||||||
val perTask = runCatching { dataSource.reminders() }.getOrElse { emptyMap() }
|
val perTask = runCatching { dataSource.reminders() }.getOrElse { emptyMap() }
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
val desired = ReminderPlanner.plan(
|
val desired = ReminderPlanner.plan(
|
||||||
tasks = tasks,
|
tasks = tasks,
|
||||||
perTask = perTask,
|
perTask = perTask,
|
||||||
settings = settings,
|
settings = settings,
|
||||||
now = System.currentTimeMillis(),
|
now = now,
|
||||||
zone = ZoneId.systemDefault(),
|
zone = ZoneId.systemDefault(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -92,7 +99,10 @@ class ReminderScheduler @Inject constructor(
|
|||||||
|
|
||||||
// ⚠️ The store survives reboot, force-stop and restore; the alarms don't.
|
// ⚠️ The store survives reboot, force-stop and restore; the alarms don't.
|
||||||
// Trusting it alone left every reminder after a reboot unarmed for good.
|
// Trusting it alone left every reminder after a reboot unarmed for good.
|
||||||
val fired = store.fired()
|
// ⚠️ Kept through the missed-reminder grace even once it drops out of
|
||||||
|
// the plan: a tick undone a moment later would otherwise re-arm a
|
||||||
|
// trigger in the past, and the reminder would ring a second time.
|
||||||
|
val fired = store.fired().filterTo(HashSet()) { it in desired || it.triggerAt >= now - ReminderPlanner.MISSED_GRACE_MS }
|
||||||
val diff = ReminderDiff.of(store.all(), desired, fired) { !afterReboot && !rearm && isArmed(it) }
|
val diff = ReminderDiff.of(store.all(), desired, fired) { !afterReboot && !rearm && isArmed(it) }
|
||||||
diff.cancel.forEach { cancel(it) }
|
diff.cancel.forEach { cancel(it) }
|
||||||
// ⚠️ Only what was actually armed. A throw mid-loop used to skip the
|
// ⚠️ Only what was actually armed. A throw mid-loop used to skip the
|
||||||
@@ -100,7 +110,25 @@ class ReminderScheduler @Inject constructor(
|
|||||||
// — uncancellable, and firing for tasks that no longer exist — and the
|
// — uncancellable, and firing for tasks that no longer exist — and the
|
||||||
// exception escaped into BootReceiver's goAsync().
|
// exception escaped into BootReceiver's goAsync().
|
||||||
val armed = diff.arm.filter { schedule(it) }
|
val armed = diff.arm.filter { schedule(it) }
|
||||||
store.replace(diff.keep + armed, fired = fired intersect desired, exact = canExact)
|
store.replace(diff.keep + armed, fired = fired, exact = canExact)
|
||||||
|
withdrawStale()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reminder already on screen outlives its alarm: completing the task in
|
||||||
|
* the list, on a widget or on another device has to take it down too.
|
||||||
|
*/
|
||||||
|
private fun withdrawStale() {
|
||||||
|
// Only our own store tells a deleted task from a failed read.
|
||||||
|
val missingMeansGone = providerResolver.mode() == StorageMode.OWN
|
||||||
|
runCatching {
|
||||||
|
notifier.withdrawStale { taskId, occurrence ->
|
||||||
|
runCatching {
|
||||||
|
val current = dataSource.occurrence(taskId, occurrence ?: ScheduledReminder.NO_OCCURRENCE)
|
||||||
|
reminderIsStale(occurrence, current, missingMeansGone)
|
||||||
|
}.getOrDefault(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether the system still holds this alarm's PendingIntent. */
|
/** Whether the system still holds this alarm's PendingIntent. */
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
import android.text.format.DateFormat
|
import android.text.format.DateFormat
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
@@ -18,6 +19,7 @@ import androidx.core.content.edit
|
|||||||
import de.jeanlucmakiola.agendula.MainActivity
|
import de.jeanlucmakiola.agendula.MainActivity
|
||||||
import de.jeanlucmakiola.agendula.R
|
import de.jeanlucmakiola.agendula.R
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.Settings
|
import de.jeanlucmakiola.agendula.data.prefs.Settings
|
||||||
|
import de.jeanlucmakiola.agendula.domain.readableDescription
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.is24Hour
|
import de.jeanlucmakiola.agendula.data.prefs.is24Hour
|
||||||
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||||
@@ -84,6 +86,7 @@ class TaskNotifier @Inject constructor(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
val description = task.description?.readableDescription()?.trim()?.takeIf { it.isNotBlank() }
|
||||||
val tapIntent = PendingIntent.getActivity(
|
val tapIntent = PendingIntent.getActivity(
|
||||||
context,
|
context,
|
||||||
task.taskId.toInt(),
|
task.taskId.toInt(),
|
||||||
@@ -93,11 +96,19 @@ class TaskNotifier @Inject constructor(
|
|||||||
val notification = NotificationCompat.Builder(context, channelId)
|
val notification = NotificationCompat.Builder(context, channelId)
|
||||||
.setSmallIcon(R.drawable.ic_notification)
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
.apply { if (text != null) setContentText(text) }
|
.apply { (text ?: description)?.let(::setContentText) }
|
||||||
|
.apply {
|
||||||
|
expandedText(text, description)?.let { setStyle(NotificationCompat.BigTextStyle().bigText(it)) }
|
||||||
|
}
|
||||||
.setCategory(NotificationCompat.CATEGORY_REMINDER)
|
.setCategory(NotificationCompat.CATEGORY_REMINDER)
|
||||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.setContentIntent(tapIntent)
|
.setContentIntent(tapIntent)
|
||||||
|
.addExtras(
|
||||||
|
Bundle().apply {
|
||||||
|
putLong(EXTRA_OCCURRENCE, task.occurrenceStart?.toEpochMilliseconds() ?: ScheduledReminder.NO_OCCURRENCE)
|
||||||
|
},
|
||||||
|
)
|
||||||
.addAction(0, context.getString(R.string.reminder_action_done), actionIntent(task, ReminderActionReceiver.ACTION_DONE))
|
.addAction(0, context.getString(R.string.reminder_action_done), actionIntent(task, ReminderActionReceiver.ACTION_DONE))
|
||||||
.addAction(
|
.addAction(
|
||||||
0,
|
0,
|
||||||
@@ -112,6 +123,24 @@ class TaskNotifier @Inject constructor(
|
|||||||
NotificationManagerCompat.from(context).cancel(taskId.toString(), NOTIFICATION_ID)
|
NotificationManagerCompat.from(context).cancel(taskId.toString(), NOTIFICATION_ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes down every posted reminder [isStale] says is done with — completed,
|
||||||
|
* cancelled or deleted since it went off, here or on another device. The
|
||||||
|
* occurrence is null for a reminder posted before it was recorded.
|
||||||
|
*/
|
||||||
|
fun withdrawStale(isStale: (taskId: Long, occurrenceStart: Long?) -> Boolean) {
|
||||||
|
// Only the current store's: another store's task ids name other tasks.
|
||||||
|
val prefix = "$LIST_CHANNEL_PREFIX${resolver.mode().name.lowercase()}_"
|
||||||
|
manager.activeNotifications
|
||||||
|
.filter { it.id == NOTIFICATION_ID && it.notification.channelId?.startsWith(prefix) == true }
|
||||||
|
.forEach { posted ->
|
||||||
|
val taskId = posted.tag?.toLongOrNull() ?: return@forEach
|
||||||
|
val extras = posted.notification.extras
|
||||||
|
val occurrence = if (extras.containsKey(EXTRA_OCCURRENCE)) extras.getLong(EXTRA_OCCURRENCE) else null
|
||||||
|
if (isStale(taskId, occurrence)) cancel(taskId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun actionIntent(task: Task, action: String): PendingIntent = PendingIntent.getBroadcast(
|
private fun actionIntent(task: Task, action: String): PendingIntent = PendingIntent.getBroadcast(
|
||||||
context,
|
context,
|
||||||
task.taskId.toInt(),
|
task.taskId.toInt(),
|
||||||
@@ -185,6 +214,7 @@ class TaskNotifier @Inject constructor(
|
|||||||
private const val LEGACY_CHANNEL_ID = "task_reminders"
|
private const val LEGACY_CHANNEL_ID = "task_reminders"
|
||||||
private const val LIST_CHANNEL_PREFIX = "reminders_"
|
private const val LIST_CHANNEL_PREFIX = "reminders_"
|
||||||
private const val NOTIFICATION_ID = 1
|
private const val NOTIFICATION_ID = 1
|
||||||
|
private const val EXTRA_OCCURRENCE = "de.jeanlucmakiola.agendula.extra.OCCURRENCE_START"
|
||||||
private const val CHANNEL_PREFS = "reminder_channels"
|
private const val CHANNEL_PREFS = "reminder_channels"
|
||||||
private const val KEY_LEGACY_IMPORTANCE = "legacy_importance"
|
private const val KEY_LEGACY_IMPORTANCE = "legacy_importance"
|
||||||
|
|
||||||
@@ -240,3 +270,10 @@ private fun relativeDay(date: LocalDate, today: LocalDate, words: RelativeDays,
|
|||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The expanded body of a due reminder: the due line, then the task's description. */
|
||||||
|
internal fun expandedText(dueLine: String?, description: String?): String? = when {
|
||||||
|
description == null -> null
|
||||||
|
dueLine == null -> description
|
||||||
|
else -> "$dueLine\n\n$description"
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.sync
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
|
||||||
|
/** Pull to sync (#73): syncs every account at once. Inert in the `offline` flavor. */
|
||||||
|
interface ManualSync {
|
||||||
|
/** Whether there is anything to sync: our own store, with at least one account. */
|
||||||
|
val available: Flow<Boolean>
|
||||||
|
|
||||||
|
/** True from [syncAll] until the syncs it started have finished. */
|
||||||
|
val syncing: StateFlow<Boolean>
|
||||||
|
|
||||||
|
fun syncAll()
|
||||||
|
|
||||||
|
object None : ManualSync {
|
||||||
|
override val available: Flow<Boolean> = flowOf(false)
|
||||||
|
override val syncing: StateFlow<Boolean> = MutableStateFlow(false)
|
||||||
|
override fun syncAll() = Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,5 +2,5 @@ package de.jeanlucmakiola.agendula.data.sync
|
|||||||
|
|
||||||
/** Asks for an account to be synced now. A no-op in the `offline` flavor. */
|
/** Asks for an account to be synced now. A no-op in the `offline` flavor. */
|
||||||
fun interface SyncRequests {
|
fun interface SyncRequests {
|
||||||
fun syncNow(accountName: String)
|
suspend fun syncNow(accountId: Long)
|
||||||
}
|
}
|
||||||
+436
-80
@@ -7,9 +7,12 @@ import android.content.ContentUris
|
|||||||
import android.content.ContentValues
|
import android.content.ContentValues
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.database.ContentObserver
|
import android.database.ContentObserver
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.database.sqlite.SQLiteException
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
|
||||||
@@ -52,14 +55,47 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
// --- reads ----------------------------------------------------------------
|
// --- reads ----------------------------------------------------------------
|
||||||
|
|
||||||
override fun taskLists(): List<TaskList> {
|
override fun taskLists(): List<TaskList> {
|
||||||
val uri = TasksContract.listsUri(authority())
|
val authority = authority()
|
||||||
val sort = "${Lists.ACCOUNT_NAME}, ${Lists.NAME}"
|
val lists = resolver.query(TasksContract.listsUri(authority), TaskProjections.LISTS, null, null, null)?.use { c ->
|
||||||
return resolver.query(uri, TaskProjections.LISTS, null, null, sort)?.use { c ->
|
|
||||||
val reader = CursorColumnReader(c)
|
val reader = CursorColumnReader(c)
|
||||||
buildList { while (c.moveToNext()) add(TaskMapper.taskList(reader)) }
|
buildList<TaskList> { while (c.moveToNext()) add(TaskMapper.taskList(reader)) }
|
||||||
} ?: emptyList()
|
} ?: return emptyList()
|
||||||
|
val order = synchronized(listOrderLock) {
|
||||||
|
val stored = storedListOrder(authority)
|
||||||
|
val known = lists.mapTo(HashSet()) { it.id }
|
||||||
|
// A list gone from the provider takes its position along, as its id may be
|
||||||
|
// reused — but not on an empty answer, which is as likely a provider mid-update.
|
||||||
|
if (lists.isNotEmpty() && stored.keys.any { it !in known }) {
|
||||||
|
stored.filterKeys { it in known }.also { storeListOrder(authority, it) }
|
||||||
|
} else {
|
||||||
|
stored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ListOrder.sorted(lists, order)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The order Agendula keeps for [authority]'s lists. */
|
||||||
|
internal fun storedListOrder(authority: String = authority()): Map<Long, Int> =
|
||||||
|
ListOrder.decode(listOrder.getString(authority, null))
|
||||||
|
|
||||||
|
private val listOrder get() = context.getSharedPreferences(LIST_ORDER, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
private fun storeListOrder(authority: String, order: Map<Long, Int>) {
|
||||||
|
listOrder.edit().putString(authority, ListOrder.encode(order)).commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kept by Agendula, as the provider's lists table has no order of its own. */
|
||||||
|
override fun reorderLists(listIds: List<Long>) {
|
||||||
|
val authority = authority()
|
||||||
|
synchronized(listOrderLock) {
|
||||||
|
storeListOrder(authority, ListOrder.reordered(taskLists().map { it.id }, listIds))
|
||||||
|
}
|
||||||
|
// Wakes the observers on the lists without asking any sync adapter to upload: nothing changed in the provider.
|
||||||
|
runCatching { resolver.notifyChange(TasksContract.listsUri(authority), null, 0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val listOrderLock = Any()
|
||||||
|
|
||||||
override fun tasks(query: TaskQuery): List<Task> {
|
override fun tasks(query: TaskQuery): List<Task> {
|
||||||
val clauses = mutableListOf<String>()
|
val clauses = mutableListOf<String>()
|
||||||
val args = mutableListOf<String>()
|
val args = mutableListOf<String>()
|
||||||
@@ -73,14 +109,22 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun task(taskId: Long): Task? {
|
override fun task(taskId: Long): Task? {
|
||||||
val rows = queryInstances("${Instances.TASK_ID} = ?", arrayOf(taskId.toString()))
|
roundLegacyAnchors()
|
||||||
return rows.firstOrNull { it.distanceFromCurrent == 0 } ?: rows.firstOrNull()
|
val rows = instanceRows(resolver.query(TasksContract.instancesUri(authority()), null, "${Instances.TASK_ID} = ?", arrayOf(taskId.toString()), null))
|
||||||
|
return (rows.firstOrNull { it.task.distanceFromCurrent == 0 } ?: rows.firstOrNull())?.let { enrich(listOf(it)).single() }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun task(taskId: Long, occurrenceStart: Instant?): Task? {
|
override fun task(taskId: Long, occurrenceStart: Instant?): Task? {
|
||||||
if (occurrenceStart == null) return task(taskId)
|
if (occurrenceStart == null) return task(taskId)
|
||||||
val rows = queryInstances("${Instances.TASK_ID} = ?", arrayOf(taskId.toString()))
|
roundLegacyAnchors()
|
||||||
return rows.firstOrNull { it.occurrenceStart == occurrenceStart } ?: task(taskId)
|
val rows = instanceRows(queryOccurrences(taskId))
|
||||||
|
val chosen = rows.firstOrNull { it.anchor == occurrenceStart }
|
||||||
|
?: rows.firstOrNull { it.anchor?.let { a -> sameSecond(a, occurrenceStart) } == true }
|
||||||
|
if (chosen != null) return enrich(listOf(chosen)).single()
|
||||||
|
tickedCopy(taskId, occurrenceStart)?.let { copy -> return task(copy)?.copy(occurrenceStart = occurrenceStart) }
|
||||||
|
// A plain task has one occurrence, whatever it was addressed by. A series
|
||||||
|
// answers null rather than another occurrence a write would then land on.
|
||||||
|
return if (seriesRules(listOf(taskId)).isEmpty()) task(taskId) else null
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun subtasks(parentTaskId: Long): List<Task> =
|
override fun subtasks(parentTaskId: Long): List<Task> =
|
||||||
@@ -107,23 +151,74 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
} ?: emptyList()
|
} ?: emptyList()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All columns and no sort: the instances view differs across provider
|
||||||
|
* versions (tasks.org's has no `is_recurring`), the by-name mapper nulls what
|
||||||
|
* is missing, and the repository re-sorts.
|
||||||
|
*/
|
||||||
private fun queryInstances(selection: String?, args: Array<String>?): List<Task> {
|
private fun queryInstances(selection: String?, args: Array<String>?): List<Task> {
|
||||||
val uri = TasksContract.instancesUri(authority())
|
roundLegacyAnchors()
|
||||||
// projection = null (all columns): the instances view differs across
|
return instanceTasks(resolver.query(TasksContract.instancesUri(authority()), null, selection, args, null))
|
||||||
// provider versions (e.g. tasks.org's bundled OpenTasks has no
|
}
|
||||||
// `is_recurring` / `distance_from_current`). The by-name mapper reads
|
|
||||||
// whatever is present and nulls the rest; the repository re-sorts, so we
|
/** Maps [cursor]'s instance rows, with the rule each series keeps on its `tasks` row, and their tags. */
|
||||||
// don't depend on a provider sort column either.
|
private fun instanceTasks(cursor: Cursor?): List<Task> = enrich(instanceRows(cursor))
|
||||||
val tasks = resolver.query(uri, null, selection, args, null)
|
|
||||||
?.use { c ->
|
/** [cursor]'s instance rows, before their rules and tags are looked up. */
|
||||||
val reader = CursorColumnReader(c)
|
private fun instanceRows(cursor: Cursor?): List<InstanceRow> {
|
||||||
buildList { while (c.moveToNext()) add(TaskMapper.task(reader)) }
|
var flagged = false
|
||||||
} ?: emptyList()
|
val rows = cursor?.use { c ->
|
||||||
if (tasks.isEmpty()) return tasks
|
flagged = c.getColumnIndex(Instances.IS_RECURRING) >= 0
|
||||||
|
val reader = CursorColumnReader(c)
|
||||||
|
buildList { while (c.moveToNext()) add(TaskMapper.instance(reader)) }
|
||||||
|
}.orEmpty()
|
||||||
|
// Where the provider flags recurrence, only flagged rows need their rule looked up.
|
||||||
|
return if (flagged) rows.map { if (it.task.isRecurring) it else it.copy(ruleOwner = NO_RULE) } else rows
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [rows] completed with the rule each series keeps on its `tasks` row, and their tags. */
|
||||||
|
private fun enrich(rows: List<InstanceRow>): List<Task> {
|
||||||
|
if (rows.isEmpty()) return emptyList()
|
||||||
|
val rules = seriesRules(rows.map { it.ruleOwner }.filter { it != NO_RULE })
|
||||||
|
val tasks = rows.map { TaskMapper.withRule(it, rules[it.ruleOwner]) }
|
||||||
val tags = categories(tasks.map { it.taskId })
|
val tags = categories(tasks.map { it.taskId })
|
||||||
return tasks.map { task -> tags[task.taskId]?.let { task.copy(tags = it) } ?: task }
|
return tasks.map { task -> tags[task.taskId]?.let { task.copy(tags = it) } ?: task }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The recurrence of each of [taskIds] that is a series, keyed by task id. */
|
||||||
|
private fun seriesRules(taskIds: Collection<Long>): Map<Long, SeriesRule> = queryByIds(
|
||||||
|
TasksContract.tasksUri(authority()),
|
||||||
|
arrayOf(Tasks.ID, Tasks.RRULE, Tasks.RDATE),
|
||||||
|
Tasks.ID,
|
||||||
|
taskIds,
|
||||||
|
filter = "${Tasks.RRULE} IS NOT NULL OR ${Tasks.RDATE} IS NOT NULL",
|
||||||
|
) { r -> r.getLong(Tasks.ID)?.let { id -> TaskMapper.seriesRule(r)?.let { id to it } } }.toMap()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rows of [uri] whose [idColumn] is one of [ids], in chunks that stay under
|
||||||
|
* SQLite's bound-argument limit, each mapped by [read] (null rows dropped).
|
||||||
|
*/
|
||||||
|
private fun <T : Any> queryByIds(
|
||||||
|
uri: Uri,
|
||||||
|
projection: Array<String>,
|
||||||
|
idColumn: String,
|
||||||
|
ids: Collection<Long>,
|
||||||
|
filter: String? = null,
|
||||||
|
filterArgs: List<String> = emptyList(),
|
||||||
|
sortOrder: String? = null,
|
||||||
|
read: (ColumnReader) -> T?,
|
||||||
|
): List<T> = ids.distinct().chunked(MAX_SQL_ARGS - filterArgs.size).flatMap { chunk ->
|
||||||
|
val selection = buildString {
|
||||||
|
append("$idColumn IN (${chunk.joinToString(",") { "?" }})")
|
||||||
|
if (filter != null) append(" AND ($filter)")
|
||||||
|
}
|
||||||
|
val args = (chunk.map(Long::toString) + filterArgs).toTypedArray()
|
||||||
|
resolver.query(uri, projection, selection, args, sortOrder)?.use { c ->
|
||||||
|
val reader = CursorColumnReader(c)
|
||||||
|
buildList { while (c.moveToNext()) read(reader)?.let(::add) }
|
||||||
|
} ?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
override fun exportTasks(listId: Long): List<ExportTask> {
|
override fun exportTasks(listId: Long): List<ExportTask> {
|
||||||
// projection = null for the same reason queryInstances uses it: the tasks
|
// projection = null for the same reason queryInstances uses it: the tasks
|
||||||
// table's shape varies across provider versions, and the by-name mapper
|
// table's shape varies across provider versions, and the by-name mapper
|
||||||
@@ -194,7 +289,7 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) {
|
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) {
|
||||||
val occurrence = task(seriesId, occurrenceStart)
|
val occurrence = occurrence(seriesId, occurrenceStart) { TaskMapper.instance(it).task } ?: task(seriesId)
|
||||||
val anchors = resolver.query(
|
val anchors = resolver.query(
|
||||||
taskUri(authority(), seriesId), arrayOf(Tasks.DTSTART, Tasks.DUE), null, null, null,
|
taskUri(authority(), seriesId), arrayOf(Tasks.DTSTART, Tasks.DUE), null, null, null,
|
||||||
)?.use { c ->
|
)?.use { c ->
|
||||||
@@ -202,14 +297,14 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
val r = CursorColumnReader(c)
|
val r = CursorColumnReader(c)
|
||||||
r.getLong(Tasks.DTSTART) to r.getLong(Tasks.DUE)
|
r.getLong(Tasks.DTSTART) to r.getLong(Tasks.DUE)
|
||||||
}
|
}
|
||||||
fun shifted(anchor: Long?, was: Instant?, now: Instant?): Long? = when {
|
val base = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id)
|
||||||
now == null -> null
|
// Without an anchor to shift, the form's own value stands — already pinned for an all-day series.
|
||||||
anchor == null || was == null -> now.toEpochMilliseconds()
|
fun shifted(column: String, anchor: Long?, was: Instant?, now: Instant?): Any? =
|
||||||
else -> anchor + (now - was).inWholeMilliseconds
|
if (now == null || anchor == null || was == null) base[column]
|
||||||
}
|
else providerMillis(anchor + (now - was).inWholeMilliseconds)
|
||||||
val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id) +
|
val values = base +
|
||||||
(Tasks.DTSTART to shifted(anchors?.first, occurrence?.start, form.start)) +
|
(Tasks.DTSTART to shifted(Tasks.DTSTART, anchors?.first, occurrence?.start, form.start)) +
|
||||||
(Tasks.DUE to shifted(anchors?.second, occurrence?.due, form.due))
|
(Tasks.DUE to shifted(Tasks.DUE, anchors?.second, occurrence?.due, form.due))
|
||||||
val rows = resolver.update(taskUri(authority(), seriesId), values.toContentValues(), null, null)
|
val rows = resolver.update(taskUri(authority(), seriesId), values.toContentValues(), null, null)
|
||||||
if (rows == 0) throw TaskWriteFailedException("update series $seriesId")
|
if (rows == 0) throw TaskWriteFailedException("update series $seriesId")
|
||||||
setCategories(seriesId, form.tags)
|
setCategories(seriesId, form.tags)
|
||||||
@@ -253,9 +348,10 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
if (!c.moveToFirst()) return@use null
|
if (!c.moveToFirst()) return@use null
|
||||||
val r = CursorColumnReader(c)
|
val r = CursorColumnReader(c)
|
||||||
val anchor = r.getLong(Tasks.DTSTART) ?: r.getLong(Tasks.DUE) ?: return@use null
|
val anchor = r.getLong(Tasks.DTSTART) ?: r.getLong(Tasks.DUE) ?: return@use null
|
||||||
|
val rule = TaskMapper.seriesRule(r)
|
||||||
RecurrenceSpec(
|
RecurrenceSpec(
|
||||||
rrule = r.getString(Tasks.RRULE)?.ifBlank { null },
|
rrule = rule?.rrule,
|
||||||
rdate = r.getString(Tasks.RDATE),
|
rdate = rule?.rdate,
|
||||||
exdate = r.getString(Tasks.EXDATE),
|
exdate = r.getString(Tasks.EXDATE),
|
||||||
anchor = Instant.fromEpochMilliseconds(anchor),
|
anchor = Instant.fromEpochMilliseconds(anchor),
|
||||||
isAllDay = r.getBoolean(Tasks.IS_ALLDAY),
|
isAllDay = r.getBoolean(Tasks.IS_ALLDAY),
|
||||||
@@ -290,7 +386,7 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
buildList {
|
buildList {
|
||||||
while (c.moveToNext()) {
|
while (c.moveToNext()) {
|
||||||
val original = r.getLong(Tasks.ORIGINAL_INSTANCE_TIME) ?: continue
|
val original = r.getLong(Tasks.ORIGINAL_INSTANCE_TIME) ?: continue
|
||||||
if (original >= from.toEpochMilliseconds()) r.getLong(Tasks.ID)?.let(::add)
|
if (providerMillis(original) >= providerMillis(from.toEpochMilliseconds())) r.getLong(Tasks.ID)?.let(::add)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} ?: emptyList()
|
} ?: emptyList()
|
||||||
@@ -302,7 +398,10 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) {
|
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) {
|
||||||
if (reminders == null) return writeInstance(taskId, occurrenceStart, form)
|
val hit = instanceFor(taskId, occurrenceStart)
|
||||||
|
?: throw TaskWriteFailedException("update instance $taskId@$occurrenceStart: no such occurrence")
|
||||||
|
hit.overrideId?.let { return updateOverride(it, form, reminders) }
|
||||||
|
if (reminders == null) return writeInstance(taskId, hit.instanceId, form)
|
||||||
// ⚠️ Written to the series, and then put back. Forking an occurrence
|
// ⚠️ Written to the series, and then put back. Forking an occurrence
|
||||||
// copies the task's *current* properties onto the new override row, so
|
// 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
|
// setting the alarm beforehand is the only way to carry it across — and
|
||||||
@@ -311,15 +410,26 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
val seriesReminders = alarmRows(taskId).map { it.second }
|
val seriesReminders = alarmRows(taskId).map { it.second }
|
||||||
writeReminders(taskId, reminders)
|
writeReminders(taskId, reminders)
|
||||||
try {
|
try {
|
||||||
writeInstance(taskId, occurrenceStart, form)
|
writeInstance(taskId, hit.instanceId, form)
|
||||||
} finally {
|
} finally {
|
||||||
setReminders(taskId, seriesReminders)
|
setReminders(taskId, seriesReminders)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun writeInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) {
|
/**
|
||||||
val instanceId = instanceIdFor(taskId, occurrenceStart)
|
* An occurrence that already has its own row is written there directly. The
|
||||||
?: throw TaskWriteFailedException("update instance $taskId@$occurrenceStart: no such occurrence")
|
* fork-and-restore dance in [updateInstance] would put its old tags and
|
||||||
|
* reminders back over the new ones.
|
||||||
|
*/
|
||||||
|
private fun updateOverride(overrideId: Long, form: TaskForm, reminders: List<TaskReminder>?) {
|
||||||
|
val values = TaskWriteMapper.instanceValues(form, ZoneId.systemDefault().id)
|
||||||
|
val rows = resolver.update(taskUri(authority(), overrideId), values.toContentValues(), null, null)
|
||||||
|
if (rows == 0) throw TaskWriteFailedException("update override $overrideId")
|
||||||
|
setCategories(overrideId, form.tags)
|
||||||
|
writeReminders(overrideId, reminders)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeInstance(taskId: Long, instanceId: Long, form: TaskForm) {
|
||||||
val values = TaskWriteMapper.instanceValues(form, ZoneId.systemDefault().id)
|
val values = TaskWriteMapper.instanceValues(form, ZoneId.systemDefault().id)
|
||||||
val uri = TasksContract.instanceUri(authority(), instanceId)
|
val uri = TasksContract.instanceUri(authority(), instanceId)
|
||||||
// The fork copies the series' properties onto the override, so the tags
|
// The fork copies the series' properties onto the override, so the tags
|
||||||
@@ -346,28 +456,147 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** An occurrence's instance row, and the override row behind it if it has one. */
|
||||||
|
private class InstanceHit(val instanceId: Long, val overrideId: Long?)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The provider's instance row id for one occurrence.
|
* The provider's instance row for one occurrence.
|
||||||
*
|
*
|
||||||
* The seam addresses occurrences by `(taskId, occurrenceStart)`; writing
|
* The seam addresses occurrences by `(taskId, occurrenceStart)`; writing
|
||||||
* through the instances URI still needs the row id, so it is looked up here
|
* through the instances URI still needs the row id, so it is looked up here
|
||||||
* rather than carried around above the data layer. Selection is on `task_id`
|
* rather than carried around above the data layer.
|
||||||
* only — the anchor is matched in Kotlin because the column that holds it
|
|
||||||
* (`instance_original_time`) is missing on older provider schemas, where a
|
|
||||||
* WHERE clause naming it would throw instead of falling back.
|
|
||||||
*/
|
*/
|
||||||
private fun instanceIdFor(taskId: Long, occurrenceStart: Instant): Long? {
|
private fun instanceFor(taskId: Long, occurrenceStart: Instant): InstanceHit? {
|
||||||
val uri = TasksContract.instancesUri(authority())
|
roundLegacyAnchors()
|
||||||
val selection = "${Instances.TASK_ID} = ?"
|
return occurrence(taskId, occurrenceStart) { r ->
|
||||||
return resolver.query(uri, null, selection, arrayOf(taskId.toString()), null)?.use { c ->
|
r.getLong(Tasks.ID)?.let { id ->
|
||||||
|
InstanceHit(id, r.getLong(Instances.TASK_ID)?.takeIf { r.getLong(Tasks.ORIGINAL_INSTANCE_ID) != null })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun instanceIdFor(taskId: Long, occurrenceStart: Instant): Long? = instanceFor(taskId, occurrenceStart)?.instanceId
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [read] applied to the instance row of [taskId]'s occurrence at
|
||||||
|
* [occurrenceStart] — its own, or an override's that replaced it — matched in
|
||||||
|
* whole seconds, as the provider expands them. The anchor is matched in
|
||||||
|
* Kotlin because `instance_original_time` is missing on older schemas.
|
||||||
|
*/
|
||||||
|
private fun <T> occurrence(taskId: Long, occurrenceStart: Instant, read: (ColumnReader) -> T): T? =
|
||||||
|
queryOccurrences(taskId)?.use { c ->
|
||||||
val reader = CursorColumnReader(c)
|
val reader = CursorColumnReader(c)
|
||||||
|
// An exact match first: a row of the same second is a fallback, not a peer.
|
||||||
|
var near: Int? = null
|
||||||
while (c.moveToNext()) {
|
while (c.moveToNext()) {
|
||||||
if (TaskMapper.occurrenceAnchor(reader) == occurrenceStart) {
|
val anchor = TaskMapper.occurrenceAnchor(reader) ?: continue
|
||||||
return@use reader.getLong(Tasks.ID)
|
if (anchor == occurrenceStart) return@use read(reader)
|
||||||
|
if (near == null && sameSecond(anchor, occurrenceStart)) near = c.position
|
||||||
|
}
|
||||||
|
near?.let { c.moveToPosition(it); read(reader) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instance rows of [taskId] and of the overrides standing in for its
|
||||||
|
* occurrences. A provider whose view lacks `original_instance_id` gets the
|
||||||
|
* task's own rows only, rather than a failed query.
|
||||||
|
*/
|
||||||
|
private fun queryOccurrences(taskId: Long): Cursor? {
|
||||||
|
val authority = authority()
|
||||||
|
val uri = TasksContract.instancesUri(authority)
|
||||||
|
val id = taskId.toString()
|
||||||
|
if (ownRowsOnlyFor != authority) {
|
||||||
|
try {
|
||||||
|
return resolver.query(uri, null, OCCURRENCE_OF, arrayOf(id, id), null)
|
||||||
|
} catch (e: SQLiteException) {
|
||||||
|
rememberIfMissingColumn(authority, e)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
rememberIfMissingColumn(authority, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resolver.query(uri, null, "${Instances.TASK_ID} = ?", arrayOf(id), null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only a view without the column is remembered; any other failure falls back for this query alone. */
|
||||||
|
private fun rememberIfMissingColumn(authority: String, e: Exception) {
|
||||||
|
val message = e.message.orEmpty()
|
||||||
|
if (Tasks.ORIGINAL_INSTANCE_ID in message || "no such column" in message || "Invalid column" in message) {
|
||||||
|
ownRowsOnlyFor = authority
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Occurrence query failed, reading the task's own rows", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The provider whose instances view turned out to lack `original_instance_id`. */
|
||||||
|
@Volatile
|
||||||
|
private var ownRowsOnlyFor: String? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var anchorsRoundedFor: String? = null
|
||||||
|
|
||||||
|
private val roundingLock = Any()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rounds the DTSTART/DUE a build before [providerMillis] stored with
|
||||||
|
* milliseconds, which the provider cannot move past a completed occurrence.
|
||||||
|
* Once per provider, before the first read: concurrent readers wait for it.
|
||||||
|
* Written as the rows' sync adapter, since the server only ever had whole
|
||||||
|
* seconds, so nothing is dirtied or re-uploaded. Overrides need nothing: the
|
||||||
|
* provider forks them at its own whole-second instance times.
|
||||||
|
*
|
||||||
|
* Never fails a read. A row the provider refuses is left as it was and tried
|
||||||
|
* again on the next start; only a pass that rounded everything is remembered.
|
||||||
|
*/
|
||||||
|
private fun roundLegacyAnchors() {
|
||||||
|
val authority = authority()
|
||||||
|
if (anchorsRoundedFor == authority) return
|
||||||
|
synchronized(roundingLock) {
|
||||||
|
if (anchorsRoundedFor == authority) return
|
||||||
|
val prefs = context.getSharedPreferences(MIGRATIONS, Context.MODE_PRIVATE)
|
||||||
|
val key = "anchors_rounded:$authority"
|
||||||
|
val done = prefs.getBoolean(key, false) || runCatching { roundAll(authority) }
|
||||||
|
.onFailure { Log.w(TAG, "Rounding legacy anchors failed", it) }
|
||||||
|
.getOrDefault(false)
|
||||||
|
if (done) prefs.edit().putBoolean(key, true).apply()
|
||||||
|
anchorsRoundedFor = authority
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when every stale row was rounded. */
|
||||||
|
private fun roundAll(authority: String): Boolean {
|
||||||
|
val columns = listOf(Tasks.DTSTART, Tasks.DUE)
|
||||||
|
val ops = resolver.query(
|
||||||
|
TasksContract.tasksUri(authority),
|
||||||
|
arrayOf(Tasks.ID, Tasks.ACCOUNT_NAME, Tasks.ACCOUNT_TYPE) + columns,
|
||||||
|
// Only series: the provider moves nothing else, so nothing else needs rounding.
|
||||||
|
"(${columns.joinToString(" OR ") { "$it % 1000 != 0" }}) AND (${Tasks.RRULE} IS NOT NULL OR ${Tasks.RDATE} IS NOT NULL)",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
)?.use { c ->
|
||||||
|
val r = CursorColumnReader(c)
|
||||||
|
buildList<ContentProviderOperation> {
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
val id = r.getLong(Tasks.ID) ?: continue
|
||||||
|
val values = buildMap<String, Any?> {
|
||||||
|
for (column in columns) {
|
||||||
|
val millis = r.getLong(column) ?: continue
|
||||||
|
if (providerMillis(millis) != millis) put(column, providerMillis(millis))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val name = r.getString(Tasks.ACCOUNT_NAME)
|
||||||
|
val type = r.getString(Tasks.ACCOUNT_TYPE)
|
||||||
|
val uri = if (name.isNullOrEmpty() || type.isNullOrEmpty()) taskUri(authority, id)
|
||||||
|
else TasksContract.asSyncAdapter(taskUri(authority, id), name, type)
|
||||||
|
add(ContentProviderOperation.newUpdate(uri).withValues(values.toContentValues()).build())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
null
|
} ?: return false
|
||||||
}
|
if (ops.isEmpty()) return true
|
||||||
|
val batch = runCatching { resolver.applyBatch(authority, ArrayList(ops)) }.getOrNull()
|
||||||
|
if (batch != null) return batch.all { (it.count ?: 0) > 0 }
|
||||||
|
// One refused row fails the whole batch; row by row, the rest still go.
|
||||||
|
return ops.map { op -> runCatching { resolver.applyBatch(authority, arrayListOf(op)).single().count ?: 0 }.getOrDefault(0) > 0 }
|
||||||
|
.all { it }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) {
|
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) {
|
||||||
@@ -430,29 +659,19 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Tags by task id for [taskIds], in stored order. */
|
/** Tags by task id for [taskIds], in stored order. */
|
||||||
private fun categories(taskIds: Collection<Long>): Map<Long, List<String>> {
|
private fun categories(taskIds: Collection<Long>): Map<Long, List<String>> = queryByIds(
|
||||||
val rows = taskIds.distinct().chunked(MAX_SQL_ARGS - 1).flatMap { chunk ->
|
TasksContract.propertiesUri(authority()),
|
||||||
val selection = "${Properties.MIMETYPE} = ? AND ${Properties.TASK_ID} IN (${chunk.joinToString(",") { "?" }})"
|
arrayOf(Properties.TASK_ID, TasksContract.Category.NAME),
|
||||||
val args = (listOf(TasksContract.Category.MIMETYPE) + chunk.map(Long::toString)).toTypedArray()
|
Properties.TASK_ID,
|
||||||
resolver.query(
|
taskIds,
|
||||||
TasksContract.propertiesUri(authority()),
|
filter = "${Properties.MIMETYPE} = ?",
|
||||||
arrayOf(Properties.TASK_ID, TasksContract.Category.NAME),
|
filterArgs = listOf(TasksContract.Category.MIMETYPE),
|
||||||
selection,
|
sortOrder = Properties.PROPERTY_ID,
|
||||||
args,
|
) { r ->
|
||||||
Properties.PROPERTY_ID,
|
val id = r.getLong(Properties.TASK_ID)
|
||||||
)?.use { c ->
|
val name = r.getString(TasksContract.Category.NAME)
|
||||||
val reader = CursorColumnReader(c)
|
if (id == null || name == null) null else id to name
|
||||||
buildList {
|
}.groupBy({ it.first }, { it.second }).mapValues { normalizeTags(it.value) }
|
||||||
while (c.moveToNext()) {
|
|
||||||
val id = reader.getLong(Properties.TASK_ID) ?: continue
|
|
||||||
val name = reader.getString(TasksContract.Category.NAME) ?: continue
|
|
||||||
add(id to name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} ?: emptyList()
|
|
||||||
}
|
|
||||||
return rows.groupBy({ it.first }, { it.second }).mapValues { normalizeTags(it.value) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replaces [taskId]'s category rows in one batch, so a refused insert cannot
|
* Replaces [taskId]'s category rows in one batch, so a refused insert cannot
|
||||||
@@ -482,16 +701,21 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun setCancelled(taskId: Long, cancelled: Boolean) {
|
override fun setCancelled(taskId: Long, cancelled: Boolean) {
|
||||||
|
if (!cancelled && movedBack(taskId, null)) return
|
||||||
val rows = resolver.update(taskUri(authority(), taskId), cancelValues(cancelled).toContentValues(), null, null)
|
val rows = resolver.update(taskUri(authority(), taskId), cancelValues(cancelled).toContentValues(), null, null)
|
||||||
if (rows == 0) throw TaskWriteFailedException("cancel task $taskId")
|
if (rows == 0) throw TaskWriteFailedException("cancel task $taskId")
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Through the instances URI, like [setCompletedInstance]. */
|
/** Through the instances URI, like [setCompletedInstance]. */
|
||||||
override fun setCancelledInstance(taskId: Long, occurrenceStart: Instant, cancelled: Boolean) {
|
override fun setCancelledInstance(taskId: Long, occurrenceStart: Instant, cancelled: Boolean) {
|
||||||
|
if (!cancelled && movedBack(taskId, occurrenceStart)) return
|
||||||
val instanceId = instanceIdFor(taskId, occurrenceStart)
|
val instanceId = instanceIdFor(taskId, occurrenceStart)
|
||||||
?: return setCancelled(taskId, cancelled)
|
?: return tickedCopy(taskId, occurrenceStart)?.let { setCancelled(it, cancelled) }
|
||||||
|
?: plainUnlessSeries(taskId, occurrenceStart) { setCancelled(taskId, cancelled) }
|
||||||
val uri = TasksContract.instanceUri(authority(), instanceId)
|
val uri = TasksContract.instanceUri(authority(), instanceId)
|
||||||
val rows = resolver.update(uri, cancelValues(cancelled).toContentValues(), null, null)
|
val rows = rememberingCopy(taskId, occurrenceStart, closing = cancelled) {
|
||||||
|
resolver.update(uri, cancelValues(cancelled).toContentValues(), null, null)
|
||||||
|
}
|
||||||
if (rows == 0) throw TaskWriteFailedException("cancel instance $instanceId")
|
if (rows == 0) throw TaskWriteFailedException("cancel instance $instanceId")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -501,6 +725,7 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
)
|
)
|
||||||
|
|
||||||
override fun setCompleted(taskId: Long, completed: Boolean) {
|
override fun setCompleted(taskId: Long, completed: Boolean) {
|
||||||
|
if (!completed && movedBack(taskId, null)) return
|
||||||
val values = TaskWriteMapper.completionValues(completed, System.currentTimeMillis())
|
val values = TaskWriteMapper.completionValues(completed, System.currentTimeMillis())
|
||||||
val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
|
val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
|
||||||
if (rows == 0) throw TaskWriteFailedException("complete task $taskId")
|
if (rows == 0) throw TaskWriteFailedException("complete task $taskId")
|
||||||
@@ -508,22 +733,133 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Through the instances URI, which is what makes the provider fork an override
|
* Through the instances URI, which is what makes the provider fork an override
|
||||||
* rather than close the series. No instance row for the anchor means the task
|
* rather than close the series. No instance row for the anchor on a task that
|
||||||
* is not a series after all — the plain write is then the right one.
|
* is not a series means the plain write is the right one.
|
||||||
*/
|
*/
|
||||||
override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) {
|
override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) {
|
||||||
|
if (!completed && movedBack(taskId, occurrenceStart)) return
|
||||||
val instanceId = instanceIdFor(taskId, occurrenceStart)
|
val instanceId = instanceIdFor(taskId, occurrenceStart)
|
||||||
?: return setCompleted(taskId, completed)
|
?: return tickedCopy(taskId, occurrenceStart)?.let { setCompleted(it, completed) }
|
||||||
|
?: plainUnlessSeries(taskId, occurrenceStart) { setCompleted(taskId, completed) }
|
||||||
val values = TaskWriteMapper.completionValues(completed, System.currentTimeMillis())
|
val values = TaskWriteMapper.completionValues(completed, System.currentTimeMillis())
|
||||||
val uri = TasksContract.instanceUri(authority(), instanceId)
|
val uri = TasksContract.instanceUri(authority(), instanceId)
|
||||||
val rows = resolver.update(uri, values.toContentValues(), null, null)
|
val rows = rememberingCopy(taskId, occurrenceStart, closing = completed) {
|
||||||
|
resolver.update(uri, values.toContentValues(), null, null)
|
||||||
|
}
|
||||||
if (rows == 0) throw TaskWriteFailedException("complete instance $instanceId")
|
if (rows == 0) throw TaskWriteFailedException("complete instance $instanceId")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs [write], and when it closed an occurrence the provider then detached
|
||||||
|
* into a task of its own, remembers that copy, so the occurrence can still be
|
||||||
|
* read and reopened, and how the series moved, so reopening can move it back.
|
||||||
|
*/
|
||||||
|
private fun rememberingCopy(taskId: Long, occurrenceStart: Instant, closing: Boolean, write: () -> Int): Int {
|
||||||
|
if (!closing) return write()
|
||||||
|
val authority = authority()
|
||||||
|
val lastId = runCatching { lastTaskId(authority) }.getOrNull() ?: return write()
|
||||||
|
val before = runCatching { seriesState(authority, taskId) }.getOrNull()
|
||||||
|
return write().also { rows ->
|
||||||
|
if (rows > 0) {
|
||||||
|
runCatching {
|
||||||
|
detachedSince(authority, lastId)?.let { copy ->
|
||||||
|
val after = before?.let { seriesState(authority, taskId) }?.takeIf { it != before }
|
||||||
|
val tick = Tick(copy, taskId.takeIf { after != null }, before.takeIf { after != null }, after, System.currentTimeMillis())
|
||||||
|
ticks.put(ticks.key(authority, taskId, occurrenceStart), tick)
|
||||||
|
}
|
||||||
|
}.onFailure { Log.w(TAG, "Looking up the detached copy failed", it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Undoes the tick that left [taskId]'s occurrence at [occurrenceStart] — or,
|
||||||
|
* with no occurrence, the tick that left the copy [taskId]: the series goes
|
||||||
|
* back where it was and the copy goes, as long as the series is still where
|
||||||
|
* the tick moved it and the copy has nothing filed under it. False when it
|
||||||
|
* cannot, so the caller reopens the copy as a task of its own.
|
||||||
|
*/
|
||||||
|
private fun movedBack(taskId: Long, occurrenceStart: Instant?): Boolean = runCatching {
|
||||||
|
val authority = authority()
|
||||||
|
val (key, tick) = occurrenceStart?.let { at -> ticks.key(authority, taskId, at).let { k -> ticks.get(k)?.let { k to it } } }
|
||||||
|
?: ticks.forCopy(authority, taskId)
|
||||||
|
?: return false
|
||||||
|
val series = tick.seriesId ?: return false
|
||||||
|
val before = tick.before ?: return false
|
||||||
|
if (seriesState(authority, series) != tick.after || subtaskIds(tick.copyId).isNotEmpty()) return false
|
||||||
|
val restore = before.filter { (column, value) -> tick.after?.get(column) != value }
|
||||||
|
val ops = arrayListOf(
|
||||||
|
ContentProviderOperation.newUpdate(taskUri(authority, series)).withValues(restore.toContentValues()).build(),
|
||||||
|
ContentProviderOperation.newDelete(taskUri(authority, tick.copyId)).build(),
|
||||||
|
)
|
||||||
|
resolver.applyBatch(authority, ops)
|
||||||
|
ticks.remove(key)
|
||||||
|
true
|
||||||
|
}.onFailure { Log.w(TAG, "Moving the series back failed", it) }.getOrDefault(false)
|
||||||
|
|
||||||
|
/** The columns a tick moves a series by, or null when [taskId] is no series. */
|
||||||
|
private fun seriesState(authority: String, taskId: Long): Map<String, Any?>? {
|
||||||
|
if (seriesRules(listOf(taskId)).isEmpty()) return null
|
||||||
|
return resolver.query(taskUri(authority, taskId), SERIES_STATE, null, null, null)?.use { c ->
|
||||||
|
if (!c.moveToFirst()) return@use null
|
||||||
|
SERIES_STATE.indices.associate { i ->
|
||||||
|
SERIES_STATE[i] to when (c.getType(i)) {
|
||||||
|
Cursor.FIELD_TYPE_NULL -> null
|
||||||
|
Cursor.FIELD_TYPE_INTEGER -> c.getLong(i)
|
||||||
|
else -> c.getString(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun lastTaskId(authority: String): Long =
|
||||||
|
resolver.query(TasksContract.tasksUri(authority), arrayOf(Tasks.ID), null, null, "${Tasks.ID} DESC")
|
||||||
|
?.use { c -> if (c.moveToFirst()) c.getLong(0) else 0L } ?: 0L
|
||||||
|
|
||||||
|
/** The one plain task the provider created after [afterId], or null when it was not exactly one. */
|
||||||
|
private fun detachedSince(authority: String, afterId: Long): Long? =
|
||||||
|
resolver.query(TasksContract.tasksUri(authority), null, "${Tasks.ID} > ?", arrayOf(afterId.toString()), null)
|
||||||
|
?.use { c ->
|
||||||
|
val r = CursorColumnReader(c)
|
||||||
|
buildList {
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
val plain = r.getString(Tasks.RRULE) == null && r.getString(Tasks.RDATE) == null &&
|
||||||
|
r.getLong(Tasks.ORIGINAL_INSTANCE_ID) == null
|
||||||
|
if (plain) r.getLong(Tasks.ID)?.let(::add)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}?.singleOrNull()
|
||||||
|
|
||||||
|
private val ticks = TickedCopies { context.getSharedPreferences(TICKED_COPIES, Context.MODE_PRIVATE) }
|
||||||
|
|
||||||
|
/** The copy a tick left for [taskId]'s occurrence at [occurrenceStart], if one was remembered. */
|
||||||
|
private fun tickedCopy(taskId: Long, occurrenceStart: Instant): Long? =
|
||||||
|
ticks.get(ticks.key(authority(), taskId, occurrenceStart))?.copyId
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [write] for a task that turned out not to be a series. A series whose
|
||||||
|
* occurrence was not found fails instead: the plain write would close all of it.
|
||||||
|
*/
|
||||||
|
private fun plainUnlessSeries(taskId: Long, occurrenceStart: Instant, write: () -> Unit) {
|
||||||
|
if (seriesRules(listOf(taskId)).isNotEmpty()) {
|
||||||
|
throw TaskWriteFailedException("instance $taskId@$occurrenceStart: no such occurrence")
|
||||||
|
}
|
||||||
|
write()
|
||||||
|
}
|
||||||
|
|
||||||
override fun deleteTask(taskId: Long) {
|
override fun deleteTask(taskId: Long) {
|
||||||
resolver.delete(taskUri(authority(), taskId), null, null)
|
resolver.delete(taskUri(authority(), taskId), null, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Through its instance, which the provider turns into an EXDATE on the master,
|
||||||
|
* as the Room store does. Deleting the row alone leaves the master without one.
|
||||||
|
*/
|
||||||
|
override fun deleteOverride(taskId: Long, occurrenceStart: Instant) {
|
||||||
|
val instanceId = instanceIdFor(taskId, occurrenceStart) ?: return deleteTask(taskId)
|
||||||
|
resolver.delete(TasksContract.instanceUri(authority(), instanceId), null, null)
|
||||||
|
}
|
||||||
|
|
||||||
override fun createLocalList(name: String, color: Int): Long {
|
override fun createLocalList(name: String, color: Int): Long {
|
||||||
val uri = TasksContract.asSyncAdapter(
|
val uri = TasksContract.asSyncAdapter(
|
||||||
TasksContract.listsUri(authority()),
|
TasksContract.listsUri(authority()),
|
||||||
@@ -594,5 +930,25 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const val LIST_ORDER = "provider_list_order"
|
||||||
|
|
||||||
|
/** An instance row of the task, or of an override standing in for one of its occurrences. */
|
||||||
|
private const val OCCURRENCE_OF = "${Instances.TASK_ID} = ? OR ${Tasks.ORIGINAL_INSTANCE_ID} = ?"
|
||||||
|
|
||||||
|
private const val MIGRATIONS = "provider_migrations"
|
||||||
|
|
||||||
|
private const val TICKED_COPIES = "provider_ticked_copies"
|
||||||
|
|
||||||
|
/** What a tick changes on a series, and an undo changes back. */
|
||||||
|
private val SERIES_STATE = arrayOf(
|
||||||
|
Tasks.DTSTART, Tasks.DUE, Tasks.DURATION, Tasks.RRULE, Tasks.RDATE, Tasks.EXDATE,
|
||||||
|
Tasks.STATUS, Tasks.COMPLETED, Tasks.PERCENT_COMPLETE,
|
||||||
|
)
|
||||||
|
|
||||||
|
private const val TAG = "AndroidTasksDataSource"
|
||||||
|
|
||||||
|
/** No rule to look up: the provider says the row does not recur. */
|
||||||
|
private const val NO_RULE = -1L
|
||||||
|
|
||||||
/** SQLite's default bound-argument limit on older Android releases. */
|
/** SQLite's default bound-argument limit on older Android releases. */
|
||||||
private const val MAX_SQL_ARGS = 999
|
private const val MAX_SQL_ARGS = 999
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agendula's own order for an external provider's lists, which the provider has
|
||||||
|
* no column for. Stored as `id=position` pairs. A list without one counts as
|
||||||
|
* position 0, as a new list does in Agendula's own store.
|
||||||
|
*/
|
||||||
|
object ListOrder {
|
||||||
|
|
||||||
|
fun decode(raw: String?): Map<Long, Int> = raw.orEmpty().split(',').mapNotNull { pair ->
|
||||||
|
val (id, position) = pair.split('=').takeIf { it.size == 2 } ?: return@mapNotNull null
|
||||||
|
id.toLongOrNull()?.let { key -> position.toIntOrNull()?.let { key to it } }
|
||||||
|
}.toMap()
|
||||||
|
|
||||||
|
fun encode(order: Map<Long, Int>): String = order.entries.joinToString(",") { "${it.key}=${it.value}" }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Positions for every list in [shown] — the lists in their current order —
|
||||||
|
* after one account's [listIds] were rearranged: those take each other's
|
||||||
|
* slots in their new order, and every other list keeps its own.
|
||||||
|
*/
|
||||||
|
fun reordered(shown: List<Long>, listIds: List<Long>): Map<Long, Int> {
|
||||||
|
val moved = listIds.iterator()
|
||||||
|
val moving = listIds.toSet()
|
||||||
|
val result = shown.map { if (it in moving && moved.hasNext()) moved.next() else it }.toMutableList()
|
||||||
|
moved.forEachRemaining { if (it !in result) result += it }
|
||||||
|
return result.withIndex().associate { it.value to it.index }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sorted(lists: List<TaskList>, order: Map<Long, Int>): List<TaskList> = lists.sortedWith(
|
||||||
|
compareBy<TaskList>({ it.accountName.lowercase() }, { order[it.id] ?: 0 }, { it.name.lowercase() })
|
||||||
|
.thenBy { it.name },
|
||||||
|
)
|
||||||
|
}
|
||||||
+10
-3
@@ -67,6 +67,7 @@ class ModeRoutingTasksDataSource(
|
|||||||
active().setCancelledInstance(taskId, occurrenceStart, cancelled)
|
active().setCancelledInstance(taskId, occurrenceStart, cancelled)
|
||||||
|
|
||||||
override fun deleteTask(taskId: Long) = active().deleteTask(taskId)
|
override fun deleteTask(taskId: Long) = active().deleteTask(taskId)
|
||||||
|
override fun deleteOverride(taskId: Long, occurrenceStart: Instant) = active().deleteOverride(taskId, occurrenceStart)
|
||||||
override fun createLocalList(name: String, color: Int): Long = active().createLocalList(name, color)
|
override fun createLocalList(name: String, color: Int): Long = active().createLocalList(name, color)
|
||||||
override fun updateList(listId: Long, name: String, color: Int) = active().updateList(listId, name, color)
|
override fun updateList(listId: Long, name: String, color: Int) = active().updateList(listId, name, color)
|
||||||
override fun deleteList(listId: Long) = active().deleteList(listId)
|
override fun deleteList(listId: Long) = active().deleteList(listId)
|
||||||
@@ -81,16 +82,22 @@ class ModeRoutingTasksDataSource(
|
|||||||
* So the registration moves with the mode, and the switch itself counts as a
|
* So the registration moves with the mode, and the switch itself counts as a
|
||||||
* change: the data underneath every live flow has just been replaced.
|
* change: the data underneath every live flow has just been replaced.
|
||||||
*/
|
*/
|
||||||
override fun registerObserver(onChange: () -> Unit): AutoCloseable {
|
override fun registerObserver(onChange: () -> Unit): AutoCloseable =
|
||||||
|
follow(onChange) { it.registerObserver(onChange) }
|
||||||
|
|
||||||
|
override fun registerTaskObserver(onChange: () -> Unit): AutoCloseable =
|
||||||
|
follow(onChange) { it.registerTaskObserver(onChange) }
|
||||||
|
|
||||||
|
private fun follow(onChange: () -> Unit, register: (TasksDataSource) -> AutoCloseable): AutoCloseable {
|
||||||
val lock = Any()
|
val lock = Any()
|
||||||
var closed = false
|
var closed = false
|
||||||
var handle: AutoCloseable? = runCatching { active().registerObserver(onChange) }.getOrNull()
|
var handle: AutoCloseable? = runCatching { register(active()) }.getOrNull()
|
||||||
|
|
||||||
val modeHandle = resolver.onModeChanged {
|
val modeHandle = resolver.onModeChanged {
|
||||||
synchronized(lock) {
|
synchronized(lock) {
|
||||||
if (!closed) {
|
if (!closed) {
|
||||||
handle?.let { runCatching { it.close() } }
|
handle?.let { runCatching { it.close() } }
|
||||||
handle = runCatching { active().registerObserver(onChange) }.getOrNull()
|
handle = runCatching { register(active()) }.getOrNull()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onChange()
|
onChange()
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.flow.debounce
|
||||||
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs [action] once per burst of store changes: [register] hooks the store's
|
||||||
|
* observer, and [request] asks for a pass without one. Started at most once.
|
||||||
|
*/
|
||||||
|
class StoreChangeDebouncer(
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val delayMillis: Long,
|
||||||
|
private val register: (onChange: () -> Unit) -> AutoCloseable,
|
||||||
|
private val action: suspend () -> Unit,
|
||||||
|
) {
|
||||||
|
private val requests = Channel<Unit>(Channel.CONFLATED)
|
||||||
|
private val started = AtomicBoolean(false)
|
||||||
|
|
||||||
|
/** @return whether this call started it; false when it already was. */
|
||||||
|
@OptIn(FlowPreview::class)
|
||||||
|
fun start(): Boolean {
|
||||||
|
if (!started.compareAndSet(false, true)) return false
|
||||||
|
runCatching { register { requests.trySend(Unit) } }
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
requests.receiveAsFlow().debounce(delayMillis).collect { runCatching { action() } }
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun request() {
|
||||||
|
requests.trySend(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import de.jeanlucmakiola.agendula.data.reminders.ReminderPlanner
|
|||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||||
|
import de.jeanlucmakiola.agendula.domain.calendarDate
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
@@ -22,7 +23,8 @@ internal object StoredReminders {
|
|||||||
*
|
*
|
||||||
* A task getting its first date takes the defaults only when the form
|
* 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
|
* 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
|
* date that no reminder of the form counts from, and those of a date
|
||||||
|
* whose defaults had all rung when it moved — see [revived]. Otherwise the form is
|
||||||
* kept, so an edit that clears every reminder means "use the default
|
* kept, so an edit that clears every reminder means "use the default
|
||||||
* here" as before.
|
* here" as before.
|
||||||
*
|
*
|
||||||
@@ -33,7 +35,7 @@ internal object StoredReminders {
|
|||||||
fun of(form: TaskForm, current: Task?, settings: Settings, now: Long, zone: ZoneId): List<TaskReminder> {
|
fun of(form: TaskForm, current: Task?, settings: Settings, now: Long, zone: ZoneId): List<TaskReminder> {
|
||||||
val wanted = form.reminders.map { it.lead }.distinct()
|
val wanted = form.reminders.map { it.lead }.distinct()
|
||||||
if (form.remindersChosen) return wanted
|
if (form.remindersChosen) return wanted
|
||||||
return withDefaults(form, current?.start, current?.due, settings, now, zone)
|
return withDefaults(form, current?.start, current?.due, current?.isAllDay ?: form.isAllDay, settings, now, zone)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** [of] for a task whose dates were [start] and [due], whatever the form chose. */
|
/** [of] for a task whose dates were [start] and [due], whatever the form chose. */
|
||||||
@@ -41,6 +43,7 @@ internal object StoredReminders {
|
|||||||
form: TaskForm,
|
form: TaskForm,
|
||||||
start: Instant?,
|
start: Instant?,
|
||||||
due: Instant?,
|
due: Instant?,
|
||||||
|
wasAllDay: Boolean,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
now: Long,
|
now: Long,
|
||||||
zone: ZoneId,
|
zone: ZoneId,
|
||||||
@@ -48,10 +51,12 @@ internal object StoredReminders {
|
|||||||
val wanted = form.reminders.map { it.lead }.distinct()
|
val wanted = form.reminders.map { it.lead }.distinct()
|
||||||
val had = anchorsOf(start, due)
|
val had = anchorsOf(start, due)
|
||||||
val gained = anchorsOf(form.start, form.due) - had
|
val gained = anchorsOf(form.start, form.due) - had
|
||||||
if (gained.isEmpty()) return wanted
|
val revived = revived(form, start, due, wasAllDay, wanted, settings, now, zone)
|
||||||
|
if (gained.isEmpty() && revived.isEmpty()) return wanted
|
||||||
if (had.isEmpty() || wanted.isEmpty()) return wanted.ifEmpty { defaultsFor(form, settings, now, zone) }
|
if (had.isEmpty() || wanted.isEmpty()) return wanted.ifEmpty { defaultsFor(form, settings, now, zone) }
|
||||||
val named = wanted.map { it.fromStart }.toSet()
|
val named = wanted.map { it.fromStart }.toSet()
|
||||||
return wanted + defaultsFor(form, settings, now, zone).filter { it.fromStart in gained && it.fromStart !in named }
|
val fresh = gained + revived
|
||||||
|
return wanted + defaultsFor(form, settings, now, zone).filter { it.fromStart in fresh && it.fromStart !in named }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -79,6 +84,41 @@ internal object StoredReminders {
|
|||||||
return kept.distinct().ifEmpty { defaultsFor(form, settings, now, zone) }
|
return kept.distinct().ifEmpty { defaultsFor(form, settings, now, zone) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dates of a one-off task that moved after every default of theirs
|
||||||
|
* had rung: a task saved then was left without them as past (#121), so
|
||||||
|
* the new date brings them like a date the task gains. Only on a task
|
||||||
|
* with reminders of its own and none from that date — no reminders at
|
||||||
|
* all is the planner's default or turned off, and either holds.
|
||||||
|
*/
|
||||||
|
private fun revived(
|
||||||
|
form: TaskForm,
|
||||||
|
start: Instant?,
|
||||||
|
due: Instant?,
|
||||||
|
wasAllDay: Boolean,
|
||||||
|
wanted: List<TaskReminder>,
|
||||||
|
settings: Settings,
|
||||||
|
now: Long,
|
||||||
|
zone: ZoneId,
|
||||||
|
): Set<Boolean> {
|
||||||
|
if (form.rrule != null || wanted.isEmpty()) return emptySet()
|
||||||
|
val named = wanted.map { it.fromStart }.toSet()
|
||||||
|
val defaults = settings.defaultRemindersFor(form.listId, wasAllDay)
|
||||||
|
fun revived(fromStart: Boolean, was: Instant?, isNow: Instant?): Boolean {
|
||||||
|
if (was == null || isNow == null || fromStart in named) return false
|
||||||
|
val moved = if (wasAllDay == form.isAllDay) {
|
||||||
|
isNow != was
|
||||||
|
} else {
|
||||||
|
isNow.calendarDate(form.isAllDay, zone) != was.calendarDate(wasAllDay, zone)
|
||||||
|
}
|
||||||
|
val leads = defaults.filter { it.fromStart == fromStart }
|
||||||
|
return moved && leads.isNotEmpty() && leads.all {
|
||||||
|
ReminderPlanner.triggerAt(was, wasAllDay, it.minutesBefore, settings.allDayReminderMinuteOfDay, zone) < now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return setOfNotNull(true.takeIf { revived(true, start, form.start) }, false.takeIf { revived(false, due, form.due) })
|
||||||
|
}
|
||||||
|
|
||||||
/** The dates a reminder can count from, as [TaskReminder.fromStart]. */
|
/** The dates a reminder can count from, as [TaskReminder.fromStart]. */
|
||||||
private fun anchorsOf(start: Any?, due: Any?): Set<Boolean> =
|
private fun anchorsOf(start: Any?, due: Any?): Set<Boolean> =
|
||||||
setOfNotNull(true.takeIf { start != null }, false.takeIf { due != null })
|
setOfNotNull(true.takeIf { start != null }, false.takeIf { due != null })
|
||||||
|
|||||||
@@ -13,20 +13,23 @@ import kotlin.time.Instant
|
|||||||
/** Maps a provider row (via [ColumnReader]) to a domain model. Pure + testable. */
|
/** Maps a provider row (via [ColumnReader]) to a domain model. Pure + testable. */
|
||||||
object TaskMapper {
|
object TaskMapper {
|
||||||
|
|
||||||
fun task(r: ColumnReader): Task {
|
/**
|
||||||
|
* Maps an `instances` row, without its series' rule: the dmfs instances view
|
||||||
|
* (DB 22 and 23) masks `rrule`/`rdate` with `NULL`, so the row alone cannot
|
||||||
|
* tell a series from a one-off on tasks.org's bundled provider (DB 22), which
|
||||||
|
* also lacks `is_recurring`. [withRule] completes it with the rule read from
|
||||||
|
* the master's `tasks` row. An override names its master in
|
||||||
|
* `original_instance_id`.
|
||||||
|
*/
|
||||||
|
fun instance(r: ColumnReader): InstanceRow {
|
||||||
fun instant(name: String): Instant? =
|
fun instant(name: String): Instant? =
|
||||||
r.getLong(name)?.let { Instant.fromEpochMilliseconds(it) }
|
r.getLong(name)?.let { Instant.fromEpochMilliseconds(it) }
|
||||||
|
|
||||||
val rowId = r.getLong(Tasks.ID) ?: 0L
|
val rowId = r.getLong(Tasks.ID) ?: 0L
|
||||||
// Derived from the rule columns rather than the `is_recurring` column
|
val masterId = r.getLong(Tasks.ORIGINAL_INSTANCE_ID)
|
||||||
// alone: that column only exists from OpenTasks 1.4.0 (DB 23) and is
|
val recurring = masterId != null || r.getBoolean(Instances.IS_RECURRING)
|
||||||
// absent on tasks.org's bundled provider (DB 22), where reading it
|
val anchor = occurrenceAnchor(r)
|
||||||
// would silently report every recurring task as one-off — and route
|
val task = Task(
|
||||||
// its edits onto the series anchor.
|
|
||||||
val recurring = r.getString(Tasks.RRULE) != null ||
|
|
||||||
r.getString(Tasks.RDATE) != null ||
|
|
||||||
r.getBoolean(Instances.IS_RECURRING)
|
|
||||||
return Task(
|
|
||||||
taskId = r.getLong(Instances.TASK_ID) ?: rowId,
|
taskId = r.getLong(Instances.TASK_ID) ?: rowId,
|
||||||
listId = r.getLong(Tasks.LIST_ID) ?: 0L,
|
listId = r.getLong(Tasks.LIST_ID) ?: 0L,
|
||||||
title = r.getString(Tasks.TITLE).orEmpty(),
|
title = r.getString(Tasks.TITLE).orEmpty(),
|
||||||
@@ -47,13 +50,31 @@ object TaskMapper {
|
|||||||
accountName = r.getString(Tasks.ACCOUNT_NAME),
|
accountName = r.getString(Tasks.ACCOUNT_NAME),
|
||||||
parentId = r.getLong(Tasks.PARENT_ID),
|
parentId = r.getLong(Tasks.PARENT_ID),
|
||||||
isRecurring = recurring,
|
isRecurring = recurring,
|
||||||
occurrenceStart = if (recurring) occurrenceAnchor(r) else null,
|
occurrenceStart = if (recurring) anchor else null,
|
||||||
distanceFromCurrent = r.getInt(Instances.DISTANCE_FROM_CURRENT),
|
distanceFromCurrent = r.getInt(Instances.DISTANCE_FROM_CURRENT),
|
||||||
created = instant(Tasks.CREATED),
|
created = instant(Tasks.CREATED),
|
||||||
lastModified = instant(Tasks.LAST_MODIFIED),
|
lastModified = instant(Tasks.LAST_MODIFIED),
|
||||||
seriesId = if (recurring) r.getLong(Instances.TASK_ID) ?: rowId else null,
|
seriesId = if (recurring) masterId ?: r.getLong(Instances.TASK_ID) ?: rowId else null,
|
||||||
recurrenceRule = r.getString(Tasks.RRULE),
|
|
||||||
)
|
)
|
||||||
|
return InstanceRow(task, ruleOwner = masterId ?: task.taskId, anchor = anchor)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [row]'s task as an occurrence of the series [rule] belongs to, or as it is without one. */
|
||||||
|
fun withRule(row: InstanceRow, rule: SeriesRule?): Task {
|
||||||
|
if (rule == null) return row.task
|
||||||
|
return row.task.copy(
|
||||||
|
isRecurring = true,
|
||||||
|
occurrenceStart = row.task.occurrenceStart ?: row.anchor,
|
||||||
|
seriesId = row.task.seriesId ?: row.ruleOwner,
|
||||||
|
recurrenceRule = rule.rrule,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The rule columns of a `tasks` row, or null when it does not recur. */
|
||||||
|
fun seriesRule(r: ColumnReader): SeriesRule? {
|
||||||
|
val rrule = r.getString(Tasks.RRULE)?.ifBlank { null }
|
||||||
|
val rdate = r.getString(Tasks.RDATE)?.ifBlank { null }
|
||||||
|
return if (rrule == null && rdate == null) null else SeriesRule(rrule, rdate)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -119,3 +140,12 @@ object TaskMapper {
|
|||||||
owner = r.getString(Lists.OWNER),
|
owner = r.getString(Lists.OWNER),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A series' recurrence as stored on its master row. */
|
||||||
|
data class SeriesRule(val rrule: String?, val rdate: String?)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An instance row's task before its series' rule is applied. [ruleOwner] is the
|
||||||
|
* task whose `tasks` row carries that rule: the master, for an override.
|
||||||
|
*/
|
||||||
|
data class InstanceRow(val task: Task, val ruleOwner: Long, val anchor: Instant?)
|
||||||
@@ -17,6 +17,20 @@ private fun Instant.forAllDay(allDay: Boolean): Instant =
|
|||||||
Math.floorDiv(toEpochMilliseconds(), MILLIS_PER_DAY) * MILLIS_PER_DAY,
|
Math.floorDiv(toEpochMilliseconds(), MILLIS_PER_DAY) * MILLIS_PER_DAY,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whole seconds, as iCalendar stores times. The provider expands a series from
|
||||||
|
* second-precision instants, so a DTSTART carrying milliseconds never matches its
|
||||||
|
* own first occurrence: completing it detaches a copy but leaves the series where
|
||||||
|
* it was, and the same occurrence comes straight back.
|
||||||
|
*/
|
||||||
|
fun providerMillis(epochMillis: Long): Long = Math.floorDiv(epochMillis, 1000L) * 1000L
|
||||||
|
|
||||||
|
/** [at] cut to its whole second, as [providerMillis] does. */
|
||||||
|
fun wholeSecond(at: Instant): Instant = Instant.fromEpochMilliseconds(providerMillis(at.toEpochMilliseconds()))
|
||||||
|
|
||||||
|
/** True when [a] and [b] fall in the same whole second. */
|
||||||
|
fun sameSecond(a: Instant, b: Instant): Boolean = wholeSecond(a) == wholeSecond(b)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turns a [TaskForm] / mutation into a name→value map. Pure (no ContentValues),
|
* Turns a [TaskForm] / mutation into a name→value map. Pure (no ContentValues),
|
||||||
* so it unit-tests on the JVM; [AndroidTasksDataSource] converts the map to
|
* so it unit-tests on the JVM; [AndroidTasksDataSource] converts the map to
|
||||||
@@ -57,8 +71,8 @@ object TaskWriteMapper {
|
|||||||
// through DateTime.toAllDay(), which drops the time-of-day and resolves the
|
// through DateTime.toAllDay(), which drops the time-of-day and resolves the
|
||||||
// remaining date against UTC — so a local-midnight instant lands on the
|
// remaining date against UTC — so a local-midnight instant lands on the
|
||||||
// previous day for anyone west of UTC. Pin all-day values to UTC midnight.
|
// previous day for anyone west of UTC. Pin all-day values to UTC midnight.
|
||||||
put(Tasks.DTSTART, form.start?.forAllDay(form.isAllDay)?.toEpochMilliseconds())
|
put(Tasks.DTSTART, form.start?.forAllDay(form.isAllDay)?.toEpochMilliseconds()?.let(::providerMillis))
|
||||||
put(Tasks.DUE, form.due?.forAllDay(form.isAllDay)?.toEpochMilliseconds())
|
put(Tasks.DUE, form.due?.forAllDay(form.isAllDay)?.toEpochMilliseconds()?.let(::providerMillis))
|
||||||
// DUE and DURATION are mutually exclusive. The provider's Validating
|
// DUE and DURATION are mutually exclusive. The provider's Validating
|
||||||
// processor evaluates the *merged* row (supplied values over the stored
|
// processor evaluates the *merged* row (supplied values over the stored
|
||||||
// ones), so writing DUE onto a task that already carries a DURATION throws
|
// ones), so writing DUE onto a task that already carries a DURATION throws
|
||||||
|
|||||||
@@ -124,13 +124,17 @@ interface TasksDataSource {
|
|||||||
*
|
*
|
||||||
* Implementations fall back to [setCompleted] when the row turns out not to
|
* Implementations fall back to [setCompleted] when the row turns out not to
|
||||||
* be a series master — an override, or a plain task the caller happened to
|
* be a series master — an override, or a plain task the caller happened to
|
||||||
* hand an anchor for — so the routing above cannot get this wrong.
|
* hand an anchor for. A series whose occurrence cannot be found fails with
|
||||||
|
* [TaskWriteFailedException] instead: the plain write would close all of it.
|
||||||
*/
|
*/
|
||||||
fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean)
|
fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean)
|
||||||
|
|
||||||
/** Cancel (or restore) one occurrence of a series; the counterpart of [setCompletedInstance]. */
|
/** Cancel (or restore) one occurrence of a series; the counterpart of [setCompletedInstance]. */
|
||||||
fun setCancelledInstance(taskId: Long, occurrenceStart: Instant, cancelled: Boolean)
|
fun setCancelledInstance(taskId: Long, occurrenceStart: Instant, cancelled: Boolean)
|
||||||
fun deleteTask(taskId: Long)
|
fun deleteTask(taskId: Long)
|
||||||
|
|
||||||
|
/** Delete the override [taskId] standing for one occurrence, excluding that occurrence from its series. */
|
||||||
|
fun deleteOverride(taskId: Long, occurrenceStart: Instant) = deleteTask(taskId)
|
||||||
fun createLocalList(name: String, color: Int): Long
|
fun createLocalList(name: String, color: Int): Long
|
||||||
|
|
||||||
/** Rename and recolour [listId]. */
|
/** Rename and recolour [listId]. */
|
||||||
@@ -152,4 +156,7 @@ interface TasksDataSource {
|
|||||||
|
|
||||||
/** Observe any change to tasks/lists; [onChange] fires on a background thread. */
|
/** Observe any change to tasks/lists; [onChange] fires on a background thread. */
|
||||||
fun registerObserver(onChange: () -> Unit): AutoCloseable
|
fun registerObserver(onChange: () -> Unit): AutoCloseable
|
||||||
|
|
||||||
|
/** Like [registerObserver], but only for changes to tasks, lists and alarms — not account bookkeeping. */
|
||||||
|
fun registerTaskObserver(onChange: () -> Unit): AutoCloseable = registerObserver(onChange)
|
||||||
}
|
}
|
||||||
@@ -68,9 +68,11 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
|
|
||||||
override fun taskDetail(taskId: Long, occurrenceStart: Instant?): Flow<TaskDetail?> = observing {
|
override fun taskDetail(taskId: Long, occurrenceStart: Instant?): Flow<TaskDetail?> = observing {
|
||||||
dataSource.task(taskId, occurrenceStart)?.let { task ->
|
dataSource.task(taskId, occurrenceStart)?.let { task ->
|
||||||
|
// A series' subtasks hang off its master, also when this occurrence is an override.
|
||||||
|
val owner = task.seriesId ?: resolvedRow(task, taskId)
|
||||||
TaskDetail(
|
TaskDetail(
|
||||||
task = task,
|
task = task,
|
||||||
subtasks = SeriesCollapse.visible(dataSource.subtasks(taskId)).filter { it.taskId != taskId },
|
subtasks = SeriesCollapse.visible(dataSource.subtasks(owner)).filter { it.taskId != owner },
|
||||||
parent = task.parentId?.takeIf { it > 0 }?.let { dataSource.task(it) },
|
parent = task.parentId?.takeIf { it > 0 }?.let { dataSource.task(it) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -172,7 +174,9 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
) = withContext(io) {
|
) = withContext(io) {
|
||||||
// Re-read just before writing: it settles the conflict check *and* tells
|
// Re-read just before writing: it settles the conflict check *and* tells
|
||||||
// us which URI to write through.
|
// us which URI to write through.
|
||||||
|
// An occurrence that is gone is not written through to its series.
|
||||||
val current = dataSource.task(taskId, occurrenceStart)
|
val current = dataSource.task(taskId, occurrenceStart)
|
||||||
|
?: if (occurrenceStart != null) throw TaskWriteFailedException("update task $taskId@$occurrenceStart: gone") else null
|
||||||
// Conflict-safe overwrite: bail if the provider's last_modified moved
|
// Conflict-safe overwrite: bail if the provider's last_modified moved
|
||||||
// since the form captured it (external sync / another app). A null
|
// since the form captured it (external sync / another app). A null
|
||||||
// baseline means "force / overwrite anyway".
|
// baseline means "force / overwrite anyway".
|
||||||
@@ -201,7 +205,7 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
// Going through the occurrence forks an override instead, which takes
|
// Going through the occurrence forks an override instead, which takes
|
||||||
// the reminders on its own row and leaves the series' alone.
|
// the reminders on its own row and leaves the series' alone.
|
||||||
val instance = current?.takeIf { it.isRecurring }?.occurrenceStart
|
val instance = current?.takeIf { it.isRecurring }?.occurrenceStart
|
||||||
if (instance == null) dataSource.updateTask(taskId, form, reminders)
|
if (instance == null) dataSource.updateTask(resolvedRow(current, taskId), form, reminders)
|
||||||
else dataSource.updateInstance(taskId, instance, form, reminders)
|
else dataSource.updateInstance(taskId, instance, form, reminders)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,14 +237,16 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
override suspend fun deleteTask(taskId: Long, occurrenceStart: Instant?, scope: RecurringScope) =
|
override suspend fun deleteTask(taskId: Long, occurrenceStart: Instant?, scope: RecurringScope) =
|
||||||
withContext(io) {
|
withContext(io) {
|
||||||
val current = dataSource.task(taskId, occurrenceStart)
|
val current = dataSource.task(taskId, occurrenceStart)
|
||||||
|
// An occurrence that is gone leaves nothing to delete; its series stays.
|
||||||
|
if (current == null && occurrenceStart != null) return@withContext
|
||||||
val seriesId = current?.seriesId
|
val seriesId = current?.seriesId
|
||||||
val occurrence = current?.occurrenceStart
|
val occurrence = current?.occurrenceStart
|
||||||
if (seriesId == null || occurrence == null) return@withContext dataSource.deleteWithSubtasks(taskId)
|
if (seriesId == null || occurrence == null) return@withContext dataSource.deleteWithSubtasks(resolvedRow(current, taskId))
|
||||||
when (scope) {
|
when (scope) {
|
||||||
RecurringScope.ThisOccurrence ->
|
RecurringScope.ThisOccurrence ->
|
||||||
// An override row deletes itself into an EXDATE; a generated
|
// An override row deletes itself into an EXDATE; a generated
|
||||||
// occurrence has no row to delete.
|
// occurrence has no row to delete.
|
||||||
if (current.taskId != seriesId) dataSource.deleteTask(current.taskId)
|
if (current.taskId != seriesId) dataSource.deleteOverride(current.taskId, occurrence)
|
||||||
else dataSource.deleteOccurrence(seriesId, occurrence)
|
else dataSource.deleteOccurrence(seriesId, occurrence)
|
||||||
RecurringScope.ThisAndFollowing -> dataSource.deleteFollowing(seriesId, occurrence)
|
RecurringScope.ThisAndFollowing -> dataSource.deleteFollowing(seriesId, occurrence)
|
||||||
RecurringScope.AllOccurrences -> dataSource.deleteWithSubtasks(seriesId)
|
RecurringScope.AllOccurrences -> dataSource.deleteWithSubtasks(seriesId)
|
||||||
@@ -291,6 +297,13 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
ticks.close()
|
ticks.close()
|
||||||
}
|
}
|
||||||
}.flowOn(io)
|
}.flowOn(io)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The row a read for [taskId] resolved to — a past occurrence the series moved
|
||||||
|
* past is a task of its own — which is the one a write has to address.
|
||||||
|
*/
|
||||||
|
private fun resolvedRow(resolved: Task?, taskId: Long): Long = resolved?.taskId ?: taskId
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Subtask levels a duplicate follows; deeper than any real list, short of a cycle. */
|
/** Subtask levels a duplicate follows; deeper than any real list, short of a cycle. */
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import org.json.JSONObject
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What ticking a provider occurrence left: the copy the provider detached, and
|
||||||
|
* the series' columns before and after the tick, so an undo can move it back.
|
||||||
|
*/
|
||||||
|
internal class Tick(
|
||||||
|
val copyId: Long,
|
||||||
|
val seriesId: Long?,
|
||||||
|
val before: Map<String, Any?>?,
|
||||||
|
val after: Map<String, Any?>?,
|
||||||
|
val at: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The provider keeps no record of its ticks, so they are kept here for [HORIZON_MS]. */
|
||||||
|
internal class TickedCopies(private val prefs: () -> SharedPreferences) {
|
||||||
|
|
||||||
|
fun key(authority: String, taskId: Long, occurrenceStart: Instant) =
|
||||||
|
"$authority|$taskId|${wholeSecond(occurrenceStart).epochSeconds}"
|
||||||
|
|
||||||
|
fun get(key: String): Tick? = prefs().getString(key, null)?.let(::decode)
|
||||||
|
|
||||||
|
/** The tick that left [copyId], with its key. */
|
||||||
|
fun forCopy(authority: String, copyId: Long): Pair<String, Tick>? =
|
||||||
|
prefs().all.entries.firstNotNullOfOrNull { (key, value) ->
|
||||||
|
if (!key.startsWith("$authority|")) return@firstNotNullOfOrNull null
|
||||||
|
(value as? String)?.let(::decode)?.takeIf { it.copyId == copyId }?.let { key to it }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun put(key: String, tick: Tick) {
|
||||||
|
val stale = prefs().all.filter { (_, value) ->
|
||||||
|
val at = (value as? String)?.let(::decode)?.at ?: 0L
|
||||||
|
tick.at - at > HORIZON_MS
|
||||||
|
}.keys
|
||||||
|
prefs().edit().apply {
|
||||||
|
stale.forEach(::remove)
|
||||||
|
putString(key, encode(tick))
|
||||||
|
}.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun remove(key: String) {
|
||||||
|
prefs().edit().remove(key).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun encode(tick: Tick): String = JSONObject().apply {
|
||||||
|
put("copy", tick.copyId)
|
||||||
|
put("at", tick.at)
|
||||||
|
tick.seriesId?.let { put("series", it) }
|
||||||
|
tick.before?.let { put("before", columns(it)) }
|
||||||
|
tick.after?.let { put("after", columns(it)) }
|
||||||
|
}.toString()
|
||||||
|
|
||||||
|
private fun columns(values: Map<String, Any?>) = JSONObject().apply {
|
||||||
|
values.forEach { (column, value) -> put(column, value ?: JSONObject.NULL) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decode(raw: String): Tick? = runCatching {
|
||||||
|
val json = JSONObject(raw)
|
||||||
|
Tick(
|
||||||
|
copyId = json.getLong("copy"),
|
||||||
|
seriesId = if (json.has("series")) json.getLong("series") else null,
|
||||||
|
before = json.optJSONObject("before")?.let(::values),
|
||||||
|
after = json.optJSONObject("after")?.let(::values),
|
||||||
|
at = json.getLong("at"),
|
||||||
|
)
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
private fun values(json: JSONObject): Map<String, Any?> = json.keys().asSequence().associateWith { column ->
|
||||||
|
when (val value = json.get(column)) {
|
||||||
|
JSONObject.NULL -> null
|
||||||
|
is Number -> value.toLong()
|
||||||
|
else -> value.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** As long as the own store keeps a tick undoable. */
|
||||||
|
const val HORIZON_MS = 30L * 24 * 60 * 60 * 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
-34
@@ -1,8 +1,7 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.tasks.provider
|
package de.jeanlucmakiola.agendula.data.tasks.provider
|
||||||
|
|
||||||
import android.database.sqlite.SQLiteConstraintException
|
import android.database.sqlite.SQLiteConstraintException
|
||||||
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
import de.jeanlucmakiola.agendula.data.reminders.ReminderRefresher
|
||||||
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
|
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
|
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TaskWriteFailedException
|
import de.jeanlucmakiola.agendula.data.tasks.TaskWriteFailedException
|
||||||
@@ -19,8 +18,6 @@ import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
|||||||
import de.jeanlucmakiola.agendula.data.tasks.room.seriesOccurrences
|
import de.jeanlucmakiola.agendula.data.tasks.room.seriesOccurrences
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||||
import de.jeanlucmakiola.agendula.domain.normalizeTags
|
import de.jeanlucmakiola.agendula.domain.normalizeTags
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -58,47 +55,40 @@ class ProviderWriter internal constructor(
|
|||||||
store: RoomTasksDataSource,
|
store: RoomTasksDataSource,
|
||||||
source: ProviderSnapshotSource,
|
source: ProviderSnapshotSource,
|
||||||
resolver: ProviderResolver,
|
resolver: ProviderResolver,
|
||||||
scheduler: ReminderScheduler,
|
reminders: ReminderRefresher,
|
||||||
@ApplicationScope scope: CoroutineScope,
|
) : this(database, store, source, resolver, reminders::requestRefresh)
|
||||||
) : this(database, store, source, resolver, { scope.launch { runCatching { scheduler.sync() } } })
|
|
||||||
|
|
||||||
private var batchDepth = 0
|
private var batchDepth = 0
|
||||||
private var batchWrote = false
|
private var batchWrote = false
|
||||||
|
|
||||||
/** Inserts one row and returns its published id. */
|
/** Inserts one row and returns its published id. */
|
||||||
fun insert(table: ProviderTable, values: ProviderValues): Long = write {
|
fun insert(table: ProviderTable, values: ProviderValues): Long = when (table) {
|
||||||
when (table) {
|
ProviderTable.TASKS -> write { insertTask(values) }
|
||||||
ProviderTable.TASKS -> insertTask(values)
|
ProviderTable.PROPERTIES -> write { insertProperty(values) }
|
||||||
ProviderTable.PROPERTIES -> insertProperty(values)
|
ProviderTable.LISTS, ProviderTable.INSTANCES -> readOnly(table)
|
||||||
else -> throw UnsupportedOperationException("${table.path} is read-only")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Updates every row [selection] matches and returns how many changed. */
|
/** Updates every row [selection] matches and returns how many changed. */
|
||||||
fun update(table: ProviderTable, values: ProviderValues, selection: String?, args: Array<out String>?): Int = write {
|
fun update(table: ProviderTable, values: ProviderValues, selection: String?, args: Array<out String>?): Int = when (table) {
|
||||||
when (table) {
|
ProviderTable.TASKS -> write {
|
||||||
ProviderTable.TASKS -> {
|
val writable = ProviderTaskWriter.writable(table, values, WRITABLE_TASK_COLUMNS)
|
||||||
val writable = ProviderTaskWriter.writable(table, values, WRITABLE_TASK_COLUMNS)
|
val matched = ids(table, selection, args)
|
||||||
val matched = ids(table, selection, args)
|
(if (Tasks.LIST_ID in writable) parentsFirst(matched) else matched).count { updateTask(it, writable) }
|
||||||
(if (Tasks.LIST_ID in writable) parentsFirst(matched) else matched).count { updateTask(it, writable) }
|
|
||||||
}
|
|
||||||
ProviderTable.INSTANCES -> {
|
|
||||||
val writable = ProviderTaskWriter.writable(table, values, WRITABLE_TASK_COLUMNS)
|
|
||||||
instances(selection, args).count { updateInstance(it, writable) }
|
|
||||||
}
|
|
||||||
ProviderTable.PROPERTIES -> ids(table, selection, args).count { updateProperty(it, values) }
|
|
||||||
else -> throw UnsupportedOperationException("${table.path} is read-only")
|
|
||||||
}
|
}
|
||||||
|
ProviderTable.INSTANCES -> write {
|
||||||
|
val writable = ProviderTaskWriter.writable(table, values, WRITABLE_TASK_COLUMNS)
|
||||||
|
instances(selection, args).count { updateInstance(it, writable) }
|
||||||
|
}
|
||||||
|
ProviderTable.PROPERTIES -> write { ids(table, selection, args).count { updateProperty(it, values) } }
|
||||||
|
ProviderTable.LISTS -> readOnly(table)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Deletes every row [selection] matches and returns how many went. */
|
/** Deletes every row [selection] matches and returns how many went. */
|
||||||
fun delete(table: ProviderTable, selection: String?, args: Array<out String>?): Int = write {
|
fun delete(table: ProviderTable, selection: String?, args: Array<out String>?): Int = when (table) {
|
||||||
when (table) {
|
ProviderTable.TASKS -> write { ids(table, selection, args).count { deleteTask(it) } }
|
||||||
ProviderTable.TASKS -> ids(table, selection, args).count { deleteTask(it) }
|
ProviderTable.INSTANCES -> write { instances(selection, args).count { deleteInstance(it) } }
|
||||||
ProviderTable.INSTANCES -> instances(selection, args).count { deleteInstance(it) }
|
ProviderTable.PROPERTIES -> write { ids(table, selection, args).count { deleteProperty(it) } }
|
||||||
ProviderTable.PROPERTIES -> ids(table, selection, args).count { deleteProperty(it) }
|
ProviderTable.LISTS -> readOnly(table)
|
||||||
else -> throw UnsupportedOperationException("${table.path} is read-only")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Runs a client's batch in one transaction, re-arming reminders once at the end. */
|
/** Runs a client's batch in one transaction, re-arming reminders once at the end. */
|
||||||
@@ -117,6 +107,9 @@ class ProviderWriter internal constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Refused as read-only whichever store is active, before the store is even looked at. */
|
||||||
|
private fun readOnly(table: ProviderTable): Nothing = throw UnsupportedOperationException("${table.path} is read-only")
|
||||||
|
|
||||||
private fun <T> write(block: () -> T): T = source.locked {
|
private fun <T> write(block: () -> T): T = source.locked {
|
||||||
check(resolver.mode() == StorageMode.OWN) {
|
check(resolver.mode() == StorageMode.OWN) {
|
||||||
"Agendula is working on top of an external tasks provider; write to that one instead"
|
"Agendula is working on top of an external tasks provider; write to that one instead"
|
||||||
@@ -196,7 +189,8 @@ class ProviderWriter internal constructor(
|
|||||||
val current = database.tasks().entity(taskId)?.takeUnless { it.isDeleted } ?: return false
|
val current = database.tasks().entity(taskId)?.takeUnless { it.isDeleted } ?: return false
|
||||||
requireWritableList(current.listId)
|
requireWritableList(current.listId)
|
||||||
// An override deletes itself into an EXDATE on its master.
|
// An override deletes itself into an EXDATE on its master.
|
||||||
if (current.masterId != null) store.deleteTask(taskId) else store.deleteWithSubtasks(taskId)
|
val occurrence = current.recurrenceId
|
||||||
|
if (current.masterId != null && occurrence != null) store.deleteOverride(taskId, occurrence) else store.deleteWithSubtasks(taskId)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ interface AccountDao {
|
|||||||
@Query("UPDATE accounts SET last_sync_at = :at, last_sync_error = :error WHERE id = :accountId")
|
@Query("UPDATE accounts SET last_sync_at = :at, last_sync_error = :error WHERE id = :accountId")
|
||||||
fun recordSync(accountId: Long, at: Instant?, error: String?)
|
fun recordSync(accountId: Long, at: Instant?, error: String?)
|
||||||
|
|
||||||
|
@Query("UPDATE accounts SET label = :label WHERE id = :accountId")
|
||||||
|
fun setLabel(accountId: Long, label: String?)
|
||||||
|
|
||||||
@Query("DELETE FROM accounts WHERE id = :accountId")
|
@Query("DELETE FROM accounts WHERE id = :accountId")
|
||||||
fun delete(accountId: Long): Int
|
fun delete(accountId: Long): Int
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||||
|
|
||||||
|
import androidx.room.ColumnInfo
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.Index
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import androidx.room.Query
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a series was moved past one of its occurrences (#137). It maps the
|
||||||
|
* occurrence to its completed copy for as long as the copy lives, and while
|
||||||
|
* [undoable] it also holds what reopening the copy needs to move the series
|
||||||
|
* back: the change, not the old series, so edits made since are kept. Local
|
||||||
|
* only: never part of a `VTODO`.
|
||||||
|
*/
|
||||||
|
@Entity(
|
||||||
|
tableName = "advanced_occurrences",
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(entity = TaskEntity::class, parentColumns = ["id"], childColumns = ["copy_id"], onDelete = ForeignKey.CASCADE),
|
||||||
|
ForeignKey(entity = TaskEntity::class, parentColumns = ["id"], childColumns = ["master_id"], onDelete = ForeignKey.CASCADE),
|
||||||
|
],
|
||||||
|
indices = [Index(value = ["master_id", "anchor"])],
|
||||||
|
)
|
||||||
|
data class AdvancedOccurrenceEntity(
|
||||||
|
/** The completed copy of the occurrence. */
|
||||||
|
@PrimaryKey @ColumnInfo(name = "copy_id") val copyId: Long,
|
||||||
|
@ColumnInfo(name = "master_id") val masterId: Long,
|
||||||
|
@ColumnInfo(name = "anchor") val anchor: Instant,
|
||||||
|
/** The anchor the series moved to; it is only moved back while it still has it. */
|
||||||
|
@ColumnInfo(name = "advanced_to") val advancedTo: Instant,
|
||||||
|
@ColumnInfo(name = "prior_dtstart") val priorDtstart: Instant?,
|
||||||
|
@ColumnInfo(name = "prior_due") val priorDue: Instant?,
|
||||||
|
@ColumnInfo(name = "prior_status") val priorStatus: TaskStatus,
|
||||||
|
@ColumnInfo(name = "prior_percent_complete") val priorPercentComplete: Int?,
|
||||||
|
@ColumnInfo(name = "prior_completed_at") val priorCompletedAt: Instant?,
|
||||||
|
/** Occurrences `COUNT` lost to the move. */
|
||||||
|
@ColumnInfo(name = "spent") val spent: Int,
|
||||||
|
/** The `RRULE` the move left; once it differs, the series was reshaped and is not moved back. */
|
||||||
|
@ColumnInfo(name = "rule_after") val ruleAfter: String?,
|
||||||
|
/** The `EXDATE` occurrences the move dropped, as epoch millis, comma-separated. */
|
||||||
|
@ColumnInfo(name = "dropped_exdates", defaultValue = "") val droppedExdates: String = "",
|
||||||
|
/** The copy was an override before, and goes back to being one. */
|
||||||
|
@ColumnInfo(name = "from_override") val fromOverride: Boolean,
|
||||||
|
/** Cleared once the copy is edited, moved or reopened on its own: it is then a task of its own. */
|
||||||
|
@ColumnInfo(name = "undoable", defaultValue = "1") val undoable: Boolean = true,
|
||||||
|
/** Set once the moved series reached the server, which then holds the move itself. */
|
||||||
|
@ColumnInfo(name = "settled", defaultValue = "0") val settled: Boolean = false,
|
||||||
|
) {
|
||||||
|
val dropped: List<Instant>
|
||||||
|
get() = droppedExdates.split(',').mapNotNull { it.toLongOrNull()?.let(Instant::fromEpochMilliseconds) }
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun encode(instants: List<Instant>) = instants.joinToString(",") { it.toEpochMilliseconds().toString() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A closed override detached along with an advance; [taskId] is null once that row is gone. */
|
||||||
|
@Entity(
|
||||||
|
tableName = "advanced_detached",
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = AdvancedOccurrenceEntity::class,
|
||||||
|
parentColumns = ["copy_id"],
|
||||||
|
childColumns = ["copy_id"],
|
||||||
|
onDelete = ForeignKey.CASCADE,
|
||||||
|
),
|
||||||
|
ForeignKey(entity = TaskEntity::class, parentColumns = ["id"], childColumns = ["task_id"], onDelete = ForeignKey.SET_NULL),
|
||||||
|
],
|
||||||
|
indices = [Index(value = ["copy_id"]), Index(value = ["task_id"])],
|
||||||
|
)
|
||||||
|
data class AdvancedDetachedEntity(
|
||||||
|
@PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0,
|
||||||
|
@ColumnInfo(name = "copy_id") val copyId: Long,
|
||||||
|
@ColumnInfo(name = "anchor") val anchor: Instant,
|
||||||
|
@ColumnInfo(name = "task_id") val taskId: Long?,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface AdvancedOccurrenceDao {
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
fun insert(row: AdvancedOccurrenceEntity)
|
||||||
|
|
||||||
|
@Insert
|
||||||
|
fun insertDetached(rows: List<AdvancedDetachedEntity>)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM advanced_occurrences WHERE copy_id = :copyId")
|
||||||
|
fun forCopy(copyId: Long): AdvancedOccurrenceEntity?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM advanced_detached WHERE copy_id = :copyId")
|
||||||
|
fun detachedFor(copyId: Long): List<AdvancedDetachedEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM advanced_occurrences WHERE master_id = :masterId")
|
||||||
|
fun forMaster(masterId: Long): List<AdvancedOccurrenceEntity>
|
||||||
|
|
||||||
|
/** The record for [masterId]'s occurrence within the second starting at [secondStart]. */
|
||||||
|
@Query(
|
||||||
|
"SELECT * FROM advanced_occurrences WHERE master_id = :masterId " +
|
||||||
|
"AND anchor >= :secondStart AND anchor < :secondStart + 1000",
|
||||||
|
)
|
||||||
|
fun at(masterId: Long, secondStart: Instant): AdvancedOccurrenceEntity?
|
||||||
|
|
||||||
|
@Query("DELETE FROM advanced_occurrences WHERE copy_id = :copyId")
|
||||||
|
fun delete(copyId: Long)
|
||||||
|
|
||||||
|
@Query("UPDATE advanced_occurrences SET undoable = 0 WHERE copy_id = :copyId")
|
||||||
|
fun keepAsOwnTask(copyId: Long)
|
||||||
|
|
||||||
|
@Query("UPDATE advanced_occurrences SET settled = 1 WHERE master_id IN (:masterIds)")
|
||||||
|
fun settle(masterIds: List<Long>)
|
||||||
|
|
||||||
|
/** Settles [masterId]'s moves to before [until], the anchor the server now holds. */
|
||||||
|
@Query("UPDATE advanced_occurrences SET settled = 1 WHERE master_id = :masterId AND advanced_to < :until")
|
||||||
|
fun settleUpTo(masterId: Long, until: Instant)
|
||||||
|
|
||||||
|
/** Records of ticks made before [before]: too old to undo. Counted from the tick, not the occurrence, which may be long overdue. */
|
||||||
|
@Query(
|
||||||
|
"DELETE FROM advanced_occurrences WHERE master_id = :masterId " +
|
||||||
|
"AND copy_id IN (SELECT id FROM tasks WHERE created_at < :before)",
|
||||||
|
)
|
||||||
|
fun pruneTickedBefore(masterId: Long, before: Instant)
|
||||||
|
|
||||||
|
/** [taskId], once detached with a tick, has a life of its own and no longer returns to the series. */
|
||||||
|
@Query("UPDATE advanced_detached SET task_id = NULL WHERE task_id = :taskId")
|
||||||
|
fun unlinkDetached(taskId: Long)
|
||||||
|
}
|
||||||
@@ -26,6 +26,8 @@ data class AccountEntity(
|
|||||||
@ColumnInfo(name = "username") val username: String? = null,
|
@ColumnInfo(name = "username") val username: String? = null,
|
||||||
@ColumnInfo(name = "last_sync_at") val lastSyncAt: Instant? = null,
|
@ColumnInfo(name = "last_sync_at") val lastSyncAt: Instant? = null,
|
||||||
@ColumnInfo(name = "last_sync_error") val lastSyncError: String? = null,
|
@ColumnInfo(name = "last_sync_error") val lastSyncError: String? = null,
|
||||||
|
/** A name the user gave the account (#105); [displayName] stays the stable key. */
|
||||||
|
@ColumnInfo(name = "label") val label: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import androidx.room.Embedded
|
|||||||
data class TaskListRow(
|
data class TaskListRow(
|
||||||
@Embedded val list: TaskListEntity,
|
@Embedded val list: TaskListEntity,
|
||||||
@ColumnInfo(name = "account_display_name") val accountDisplayName: String?,
|
@ColumnInfo(name = "account_display_name") val accountDisplayName: String?,
|
||||||
|
@ColumnInfo(name = "account_label") val accountLabel: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,5 +24,6 @@ data class TaskRow(
|
|||||||
@ColumnInfo(name = "list_name") val listName: String,
|
@ColumnInfo(name = "list_name") val listName: String,
|
||||||
@ColumnInfo(name = "list_color") val listColor: Int,
|
@ColumnInfo(name = "list_color") val listColor: Int,
|
||||||
@ColumnInfo(name = "account_display_name") val accountDisplayName: String?,
|
@ColumnInfo(name = "account_display_name") val accountDisplayName: String?,
|
||||||
|
@ColumnInfo(name = "account_label") val accountLabel: String? = null,
|
||||||
@ColumnInfo(name = "list_read_only") val listReadOnly: Boolean = false,
|
@ColumnInfo(name = "list_read_only") val listReadOnly: Boolean = false,
|
||||||
)
|
)
|
||||||
@@ -4,6 +4,7 @@ import de.jeanlucmakiola.agendula.domain.LocalAccount
|
|||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
import de.jeanlucmakiola.agendula.domain.export.ExportTask
|
import de.jeanlucmakiola.agendula.domain.export.ExportTask
|
||||||
|
import de.jeanlucmakiola.agendula.domain.isClosed
|
||||||
import de.jeanlucmakiola.agendula.domain.priorityFromICal
|
import de.jeanlucmakiola.agendula.domain.priorityFromICal
|
||||||
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
|
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
@@ -20,7 +21,7 @@ object RoomTaskMapper {
|
|||||||
color = row.list.color,
|
color = row.list.color,
|
||||||
// TaskList.accountName is non-null and the lists screen groups by it, so a
|
// TaskList.accountName is non-null and the lists screen groups by it, so a
|
||||||
// list with no account still has to report something to group under.
|
// list with no account still has to report something to group under.
|
||||||
accountName = row.accountDisplayName ?: LocalAccount.NAME,
|
accountName = row.accountLabel ?: row.accountDisplayName ?: LocalAccount.NAME,
|
||||||
accountType = if (row.list.accountId == null) LocalAccount.TYPE else CALDAV_ACCOUNT_TYPE,
|
accountType = if (row.list.accountId == null) LocalAccount.TYPE else CALDAV_ACCOUNT_TYPE,
|
||||||
isSynced = row.list.isSynced,
|
isSynced = row.list.isSynced,
|
||||||
isVisible = row.list.isVisible,
|
isVisible = row.list.isVisible,
|
||||||
@@ -61,7 +62,7 @@ object RoomTaskMapper {
|
|||||||
listColor = row.listColor,
|
listColor = row.listColor,
|
||||||
taskColor = row.task.color,
|
taskColor = row.task.color,
|
||||||
listName = row.listName,
|
listName = row.listName,
|
||||||
accountName = row.accountDisplayName ?: LocalAccount.NAME,
|
accountName = row.accountLabel ?: row.accountDisplayName ?: LocalAccount.NAME,
|
||||||
parentId = row.task.parentId,
|
parentId = row.task.parentId,
|
||||||
isRecurring = row.task.isRecurring,
|
isRecurring = row.task.isRecurring,
|
||||||
occurrenceStart = occurrenceStart,
|
occurrenceStart = occurrenceStart,
|
||||||
@@ -100,6 +101,10 @@ object RoomTaskMapper {
|
|||||||
val TaskEntity.isRecurring: Boolean
|
val TaskEntity.isRecurring: Boolean
|
||||||
get() = !rrule.isNullOrBlank() || !rdate.isNullOrBlank()
|
get() = !rrule.isNullOrBlank() || !rdate.isNullOrBlank()
|
||||||
|
|
||||||
|
/** Completed or cancelled. */
|
||||||
|
val TaskEntity.isClosed: Boolean
|
||||||
|
get() = status.isClosed
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The series anchor: `DTSTART` when present, else `DUE`. A `VTODO` may carry only
|
* The series anchor: `DTSTART` when present, else `DUE`. A `VTODO` may carry only
|
||||||
* a due date, and RFC 5545 then anchors the recurrence on it — matching how the
|
* a due date, and RFC 5545 then anchors the recurrence on it — matching how the
|
||||||
|
|||||||
+368
-56
@@ -6,13 +6,17 @@ import de.jeanlucmakiola.agendula.domain.TaskReminder
|
|||||||
import de.jeanlucmakiola.agendula.domain.TaskReminderOrder
|
import de.jeanlucmakiola.agendula.domain.TaskReminderOrder
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TaskWriteFailedException
|
import de.jeanlucmakiola.agendula.data.tasks.TaskWriteFailedException
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.sameSecond
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.wholeSecond
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||||
import de.jeanlucmakiola.agendula.domain.export.ExportTask
|
import de.jeanlucmakiola.agendula.domain.export.ExportTask
|
||||||
|
import de.jeanlucmakiola.agendula.domain.normalizeTags
|
||||||
import de.jeanlucmakiola.agendula.domain.recurrence.ExpansionWindow
|
import de.jeanlucmakiola.agendula.domain.recurrence.ExpansionWindow
|
||||||
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander
|
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander
|
||||||
|
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -28,6 +32,12 @@ private val WINDOW_FORWARD = 730.days
|
|||||||
/** Residue lines that describe the old series and must not follow the new one. */
|
/** Residue lines that describe the old series and must not follow the new one. */
|
||||||
private val SERIES_LINES = listOf("EXDATE", "RDATE", "RRULE", "RECURRENCE-ID", "UID")
|
private val SERIES_LINES = listOf("EXDATE", "RDATE", "RRULE", "RECURRENCE-ID", "UID")
|
||||||
|
|
||||||
|
/** How long a record keeps mapping a ticked occurrence to its copy. */
|
||||||
|
private val RECORD_HORIZON = 30.days
|
||||||
|
|
||||||
|
/** Only bounds the search: the occurrence count is what stops expansion, so no real gap is cut off. */
|
||||||
|
private val SERIES_LOOKAHEAD = (366 * 1000).days
|
||||||
|
|
||||||
/** Enough to count every occurrence before a split point without bounding a real series. */
|
/** Enough to count every occurrence before a split point without bounding a real series. */
|
||||||
internal const val SPLIT_COUNT_CEILING = 100_000
|
internal const val SPLIT_COUNT_CEILING = 100_000
|
||||||
|
|
||||||
@@ -76,6 +86,7 @@ internal fun seriesOccurrences(master: TaskEntity, overrides: List<TaskEntity>,
|
|||||||
}
|
}
|
||||||
|
|
||||||
internal val OBSERVED_TABLES = arrayOf("tasks", "task_lists", "task_alarms", "accounts")
|
internal val OBSERVED_TABLES = arrayOf("tasks", "task_lists", "task_alarms", "accounts")
|
||||||
|
private val TASK_TABLES = arrayOf("tasks", "task_lists", "task_alarms")
|
||||||
|
|
||||||
/** Distinct alarm texts whose `ACKNOWLEDGED` is remembered between scheduler passes. */
|
/** Distinct alarm texts whose `ACKNOWLEDGED` is remembered between scheduler passes. */
|
||||||
private const val ACK_CACHE_SIZE = 512
|
private const val ACK_CACHE_SIZE = 512
|
||||||
@@ -102,6 +113,7 @@ class RoomTasksDataSource @Inject constructor(
|
|||||||
private val tasks get() = database.tasks()
|
private val tasks get() = database.tasks()
|
||||||
private val lists get() = database.taskLists()
|
private val lists get() = database.taskLists()
|
||||||
private val alarms get() = database.alarms()
|
private val alarms get() = database.alarms()
|
||||||
|
private val advanced get() = database.advancedOccurrences()
|
||||||
|
|
||||||
// --- reads ----------------------------------------------------------------
|
// --- reads ----------------------------------------------------------------
|
||||||
|
|
||||||
@@ -129,9 +141,13 @@ class RoomTasksDataSource @Inject constructor(
|
|||||||
if (occurrenceStart == null) return task(taskId)
|
if (occurrenceStart == null) return task(taskId)
|
||||||
val row = tasks.task(taskId) ?: return null
|
val row = tasks.task(taskId) ?: return null
|
||||||
if (row.task.recurrenceId != null || !row.task.isRecurring) return task(taskId)
|
if (row.task.recurrenceId != null || !row.task.isRecurring) return task(taskId)
|
||||||
return occurrencesOf(row, tasks.overrides(taskId), clock.now())
|
val occurrences = occurrencesOf(row, tasks.overrides(taskId), clock.now())
|
||||||
.firstOrNull { it.occurrenceStart == occurrenceStart }
|
// Null rather than another occurrence: a write addressed to this one must not land on that.
|
||||||
?: task(taskId)
|
return occurrences.firstOrNull { it.occurrenceStart == occurrenceStart }
|
||||||
|
?: occurrences.firstOrNull { it.occurrenceStart?.let { a -> sameSecond(a, occurrenceStart) } == true }
|
||||||
|
?: copyAt(row.task, occurrenceStart)?.let { copy ->
|
||||||
|
tasks.task(copy.id)?.let { RoomTaskMapper.task(it).copy(occurrenceStart = occurrenceStart) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun overrideTask(row: TaskRow, anchor: Instant): Task =
|
private fun overrideTask(row: TaskRow, anchor: Instant): Task =
|
||||||
@@ -229,10 +245,8 @@ class RoomTasksDataSource @Inject constructor(
|
|||||||
* reminders sync, a sync landing between the two would upload the task
|
* reminders sync, a sync landing between the two would upload the task
|
||||||
* without them, or with the ones it is about to lose.
|
* without them, or with the ones it is about to lose.
|
||||||
*/
|
*/
|
||||||
private fun writeReminders(taskId: Long, reminders: List<TaskReminder>?) {
|
private fun writeReminders(taskId: Long, reminders: List<TaskReminder>?): Boolean =
|
||||||
if (reminders == null) return
|
reminders != null && alarms.syncForTask(taskId, reminders)
|
||||||
alarms.syncForTask(taskId, reminders)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun storedReminders(taskId: Long): List<TaskReminder> =
|
private fun storedReminders(taskId: Long): List<TaskReminder> =
|
||||||
alarms.forTask(taskId).map { it.asReminder() }.sortedWith(TaskReminderOrder)
|
alarms.forTask(taskId).map { it.asReminder() }.sortedWith(TaskReminderOrder)
|
||||||
@@ -240,7 +254,7 @@ class RoomTasksDataSource @Inject constructor(
|
|||||||
override fun updateTask(taskId: Long, form: TaskForm, reminders: List<TaskReminder>?) = tx {
|
override fun updateTask(taskId: Long, form: TaskForm, reminders: List<TaskReminder>?) = tx {
|
||||||
val current = tasks.entity(taskId) ?: throw TaskWriteFailedException("update task $taskId")
|
val current = tasks.entity(taskId) ?: throw TaskWriteFailedException("update task $taskId")
|
||||||
val edited = TaskFormWriter.apply(current, form, clock.now(), zone())
|
val edited = TaskFormWriter.apply(current, form, clock.now(), zone())
|
||||||
writeReminders(taskId, reminders)
|
val remindersChanged = writeReminders(taskId, reminders)
|
||||||
if (current.masterId == null && edited.listId != current.listId) {
|
if (current.masterId == null && edited.listId != current.listId) {
|
||||||
if (lists.exists(edited.listId) == 0) throw TaskWriteFailedException("move task: no list ${edited.listId}")
|
if (lists.exists(edited.listId) == 0) throw TaskWriteFailedException("move task: no list ${edited.listId}")
|
||||||
move(current, TaskFormWriter.withRule(edited, form.rrule))
|
move(current, TaskFormWriter.withRule(edited, form.rrule))
|
||||||
@@ -255,18 +269,23 @@ class RoomTasksDataSource @Inject constructor(
|
|||||||
// in both (the task query skips non-null `master_id`, and the override
|
// in both (the task query skips non-null `master_id`, and the override
|
||||||
// query finds no master in B) while still uploading as part of A's
|
// query finds no master in B) while still uploading as part of A's
|
||||||
// resource.
|
// resource.
|
||||||
tasks.update(
|
saveRow(
|
||||||
|
current,
|
||||||
if (current.masterId == null) {
|
if (current.masterId == null) {
|
||||||
TaskFormWriter.withRule(edited, form.rrule)
|
TaskFormWriter.withRule(edited, form.rrule)
|
||||||
} else {
|
} else {
|
||||||
edited.copy(listId = current.listId, parentId = current.parentId)
|
edited.copy(listId = current.listId, parentId = current.parentId)
|
||||||
},
|
},
|
||||||
|
changedBesides = remindersChanged,
|
||||||
)
|
)
|
||||||
writes.onWritten(setOf(current.listId))
|
writes.onWritten(setOf(current.listId))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** See [TaskMove]: the rows move, and the old collection is left a tombstone. */
|
/** See [TaskMove]: the rows move, and the old collection is left a tombstone. */
|
||||||
private fun move(current: TaskEntity, edited: TaskEntity) {
|
private fun move(current: TaskEntity, edited: TaskEntity) {
|
||||||
|
// A copy moved elsewhere is a task of its own now.
|
||||||
|
advanced.keepAsOwnTask(current.id)
|
||||||
|
advanced.unlinkDetached(current.id)
|
||||||
val plan = TaskMove.plan(
|
val plan = TaskMove.plan(
|
||||||
edited = edited,
|
edited = edited,
|
||||||
previous = current,
|
previous = current,
|
||||||
@@ -322,11 +341,7 @@ class RoomTasksDataSource @Inject constructor(
|
|||||||
return@tx
|
return@tx
|
||||||
}
|
}
|
||||||
val now = clock.now()
|
val now = clock.now()
|
||||||
// COUNT counts before EXDATE is applied (RFC 5545 §3.8.5.3).
|
val spent = spentBefore(spec, occurrenceStart)
|
||||||
val spent = RecurrenceExpander.expand(
|
|
||||||
spec.copy(exdate = null),
|
|
||||||
ExpansionWindow(from = anchor, until = occurrenceStart, maxOccurrences = SPLIT_COUNT_CEILING),
|
|
||||||
).size
|
|
||||||
val (start, due) = resolvedTimes(master, occurrenceStart)
|
val (start, due) = resolvedTimes(master, occurrenceStart)
|
||||||
tasks.update(TaskFormWriter.endedBefore(master, occurrenceStart, now, zone()))
|
tasks.update(TaskFormWriter.endedBefore(master, occurrenceStart, now, zone()))
|
||||||
|
|
||||||
@@ -349,9 +364,7 @@ class RoomTasksDataSource @Inject constructor(
|
|||||||
percentComplete = null,
|
percentComplete = null,
|
||||||
completedAt = null,
|
completedAt = null,
|
||||||
// Recurrence lines belong to the old series; everything else carries over.
|
// Recurrence lines belong to the old series; everything else carries over.
|
||||||
unknownProperties = master.unknownProperties?.lines()
|
unknownProperties = seriesFreeText(master.unknownProperties),
|
||||||
?.filterNot { line -> SERIES_LINES.any { line.startsWith(it, ignoreCase = true) } }
|
|
||||||
?.joinToString("\r\n")?.ifBlank { null },
|
|
||||||
)
|
)
|
||||||
val applied = TaskFormWriter.apply(base, form.copy(percentComplete = null), now, zone())
|
val applied = TaskFormWriter.apply(base, form.copy(percentComplete = null), now, zone())
|
||||||
val delta = applied.dtstart?.let { newStart -> start?.let { newStart - it } }
|
val delta = applied.dtstart?.let { newStart -> start?.let { newStart - it } }
|
||||||
@@ -410,9 +423,12 @@ class RoomTasksDataSource @Inject constructor(
|
|||||||
|
|
||||||
private inline fun tx(crossinline block: () -> Unit) = database.runInTransaction(Runnable { block() })
|
private inline fun tx(crossinline block: () -> Unit) = database.runInTransaction(Runnable { block() })
|
||||||
|
|
||||||
private fun seriesMaster(seriesId: Long): TaskEntity {
|
private fun seriesMaster(seriesId: Long): TaskEntity = rowAndMaster(seriesId).second
|
||||||
val row = tasks.entity(seriesId) ?: throw TaskWriteFailedException("series $seriesId")
|
|
||||||
return row.masterId?.let { tasks.entity(it) } ?: row
|
/** [taskId]'s row, and its series' master — the row itself unless it is an override. */
|
||||||
|
private fun rowAndMaster(taskId: Long): Pair<TaskEntity, TaskEntity> {
|
||||||
|
val row = tasks.entity(taskId) ?: throw TaskWriteFailedException("series $taskId")
|
||||||
|
return row to (row.masterId?.let { tasks.entity(it) } ?: row)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The occurrence's own times: its override's if it has one, else generated. */
|
/** The occurrence's own times: its override's if it has one, else generated. */
|
||||||
@@ -459,62 +475,354 @@ class RoomTasksDataSource @Inject constructor(
|
|||||||
|
|
||||||
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) = tx {
|
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) = tx {
|
||||||
if (!alarms.syncForTask(taskId, reminders)) return@tx
|
if (!alarms.syncForTask(taskId, reminders)) return@tx
|
||||||
|
advanced.keepAsOwnTask(taskId)
|
||||||
|
advanced.unlinkDetached(taskId)
|
||||||
// The reminders are part of the VTODO now, so changing them is an edit.
|
// The reminders are part of the VTODO now, so changing them is an edit.
|
||||||
val current = tasks.entity(taskId) ?: return@tx
|
val current = tasks.entity(taskId) ?: return@tx
|
||||||
tasks.update(current.copy(isDirty = true, lastModified = clock.now()))
|
tasks.update(current.copy(isDirty = true, lastModified = clock.now()))
|
||||||
writes.onWritten(setOf(current.listId))
|
writes.onWritten(setOf(current.listId))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setCancelled(taskId: Long, cancelled: Boolean) {
|
/** Reopening the copy a ticked occurrence left behind moves its series back, as in [setCompletedInstance]. */
|
||||||
|
override fun setCancelled(taskId: Long, cancelled: Boolean) = tx {
|
||||||
val current = tasks.entity(taskId) ?: throw TaskWriteFailedException("cancel task $taskId")
|
val current = tasks.entity(taskId) ?: throw TaskWriteFailedException("cancel task $taskId")
|
||||||
tasks.update(TaskFormWriter.cancelled(current, cancelled, clock.now()))
|
saveRow(current, TaskFormWriter.cancelled(current, cancelled, clock.now()))
|
||||||
writes.onWritten(setOf(current.listId))
|
writes.onWritten(setOf(current.listId))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setCompleted(taskId: Long, completed: Boolean) {
|
/** Reopening the copy a ticked occurrence left behind moves its series back, as in [setCompletedInstance]. */
|
||||||
|
override fun setCompleted(taskId: Long, completed: Boolean) = tx {
|
||||||
val current = tasks.entity(taskId) ?: throw TaskWriteFailedException("complete task $taskId")
|
val current = tasks.entity(taskId) ?: throw TaskWriteFailedException("complete task $taskId")
|
||||||
tasks.update(TaskFormWriter.completed(current, completed, clock.now()))
|
saveRow(current, TaskFormWriter.completed(current, completed, clock.now()))
|
||||||
writes.onWritten(setOf(current.listId))
|
writes.onWritten(setOf(current.listId))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ticking one occurrence forks a `RECURRENCE-ID` override carrying the
|
* Ticking a series' first open occurrence moves the series on to its next
|
||||||
* completion — the same model [updateInstance] writes. Writing the status onto
|
* open one and keeps a completed copy, as tasks.org and Apple Reminders do — a
|
||||||
* the master instead would close the series: the master is what
|
* completed `RECURRENCE-ID` override alone leaves the series showing as open
|
||||||
* [TaskDao.tasks] filters on, so every occurrence, past and future, would
|
* in most other clients (#137). Closed overrides between become copies too.
|
||||||
* leave every list at once.
|
* Reopening the copy while the series is still where that left it moves it
|
||||||
|
* back. Any other occurrence forks an override carrying the completion, the
|
||||||
|
* same model [updateInstance] writes. Writing the status onto the master
|
||||||
|
* instead would close the series.
|
||||||
*/
|
*/
|
||||||
override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) =
|
override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) = tx {
|
||||||
writeInstance(taskId, occurrenceStart, "complete") { TaskFormWriter.completed(it, completed, clock.now()) }
|
closeOccurrence(taskId, occurrenceStart, completed) { TaskFormWriter.completed(it, completed, clock.now()) }
|
||||||
|
}
|
||||||
|
|
||||||
/** Same override model as [setCompletedInstance]. */
|
/** Same model as [setCompletedInstance]. */
|
||||||
override fun setCancelledInstance(taskId: Long, occurrenceStart: Instant, cancelled: Boolean) =
|
override fun setCancelledInstance(taskId: Long, occurrenceStart: Instant, cancelled: Boolean) = tx {
|
||||||
writeInstance(taskId, occurrenceStart, "cancel") { TaskFormWriter.cancelled(it, cancelled, clock.now()) }
|
closeOccurrence(taskId, occurrenceStart, cancelled) { TaskFormWriter.cancelled(it, cancelled, clock.now()) }
|
||||||
|
}
|
||||||
|
|
||||||
/** Applies [write] to the override for [occurrenceStart], forking one if there is none yet. */
|
private fun closeOccurrence(taskId: Long, occurrenceStart: Instant, closing: Boolean, close: (TaskEntity) -> TaskEntity) {
|
||||||
private fun writeInstance(
|
val (row, master) = rowAndMaster(taskId)
|
||||||
taskId: Long,
|
|
||||||
occurrenceStart: Instant,
|
|
||||||
what: String,
|
|
||||||
write: (TaskEntity) -> TaskEntity,
|
|
||||||
) {
|
|
||||||
val master = tasks.entity(taskId) ?: throw TaskWriteFailedException("$what instance $taskId")
|
|
||||||
// Not a series master — an override, or a plain task the caller handed an
|
|
||||||
// anchor for. Either way this row *is* the occurrence.
|
|
||||||
if (master.recurrenceSpec() == null) {
|
if (master.recurrenceSpec() == null) {
|
||||||
tasks.update(write(master))
|
// A plain task, or the copy a ticked occurrence left: this row is the occurrence.
|
||||||
writes.onWritten(setOf(master.listId))
|
saveRow(row, close(row))
|
||||||
|
writes.onWritten(setOf(row.listId))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
val override = if (row.masterId != null) row else tasks.override(master.id, occurrenceStart)
|
||||||
|
val anchor = override?.recurrenceId ?: occurrenceStart
|
||||||
|
val seriesStart = master.recurrenceAnchor ?: return
|
||||||
|
if (anchor < seriesStart && !sameSecond(anchor, seriesStart)) {
|
||||||
|
// The series has moved past it, so a tick has nothing left to close;
|
||||||
|
// a reopen goes to the copy that occurrence left.
|
||||||
|
when {
|
||||||
|
override != null -> writeInstance(master, override, anchor, close)
|
||||||
|
!closing -> copyAt(master, anchor)?.let { copy ->
|
||||||
|
saveRow(copy, close(copy))
|
||||||
|
writes.onWritten(setOf(copy.listId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (master.isClosed) {
|
||||||
|
// A series ends with its last occurrence; reopening that one reopens the
|
||||||
|
// series. An override reopens alone while other occurrences are closed
|
||||||
|
// only through the master, which reopening it would reopen too.
|
||||||
|
val reopensSeries = !closing && (override == null || !closedThroughMaster(master, anchor))
|
||||||
|
if (reopensSeries) closeSeries(master, override, close) else writeInstance(master, override, anchor, close)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!closing || !advance(master, anchor, override, close)) writeInstance(master, override, anchor, close)
|
||||||
|
}
|
||||||
|
|
||||||
tasks.override(taskId, occurrenceStart)?.let {
|
/**
|
||||||
tasks.update(write(it))
|
* Writes [edited] over [current]. A copy whose only change is that it
|
||||||
writes.onWritten(setOf(master.listId))
|
* reopens moves its series back instead; any other edit, [changedBesides]
|
||||||
return
|
* included, makes it a task of its own, and it no longer stands for the
|
||||||
|
* series' occurrence. So does any edit to an occurrence detached with it.
|
||||||
|
*/
|
||||||
|
private fun saveRow(current: TaskEntity, edited: TaskEntity, changedBesides: Boolean = false) {
|
||||||
|
advanced.unlinkDetached(current.id)
|
||||||
|
val record = advanced.forCopy(current.id)?.takeIf { it.undoable }
|
||||||
|
if (record != null) {
|
||||||
|
val unchanged = !changedBesides && sameApartFromState(current, edited)
|
||||||
|
if (unchanged && current.isClosed && !edited.isClosed && reopenCopy(current, edited, record)) return
|
||||||
|
if (!unchanged || !edited.isClosed) advanced.keepAsOwnTask(current.id)
|
||||||
|
}
|
||||||
|
tasks.update(edited)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nothing a user sees changed but its state — what saving a reopened copy
|
||||||
|
* from the editor amounts to. Compared as the editor writes them back:
|
||||||
|
* trimmed, blank as none, tags normalised.
|
||||||
|
*/
|
||||||
|
private fun sameApartFromState(a: TaskEntity, b: TaskEntity): Boolean {
|
||||||
|
fun text(value: String?) = value?.trim()?.ifEmpty { null }
|
||||||
|
return text(a.title) == text(b.title) && text(a.description) == text(b.description) &&
|
||||||
|
text(a.location) == text(b.location) && text(a.url) == text(b.url) &&
|
||||||
|
a.priority == b.priority && a.dtstart == b.dtstart && a.due == b.due && a.isAllDay == b.isAllDay &&
|
||||||
|
a.listId == b.listId && a.parentId == b.parentId && a.rrule == b.rrule && a.rdate == b.rdate &&
|
||||||
|
normalizeTags(a.categories) == normalizeTags(b.categories) && a.color == b.color
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether an occurrence of [master] other than [except] has no override, so its state is the master's. */
|
||||||
|
private fun closedThroughMaster(master: TaskEntity, except: Instant): Boolean {
|
||||||
|
val spec = master.recurrenceSpec() ?: return false
|
||||||
|
val overridden = tasks.overrides(master.id).mapNotNull { it.recurrenceId?.let(::wholeSecond) }.toSet()
|
||||||
|
return expandFrom(spec, maxOccurrences = overridden.size + 2)
|
||||||
|
.any { !sameSecond(it, except) && wholeSecond(it) !in overridden }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [close] applied to the master — and to [override], its last occurrence — which ends the series. */
|
||||||
|
private fun closeSeries(master: TaskEntity, override: TaskEntity?, close: (TaskEntity) -> TaskEntity) {
|
||||||
|
tasks.update(close(master))
|
||||||
|
override?.let { tasks.update(close(it)) }
|
||||||
|
writes.onWritten(setOf(master.listId))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves [master] past [anchor] when that is its first open occurrence; false
|
||||||
|
* when it is not, so the caller writes an override. Series with `RDATE`s keep
|
||||||
|
* the override model (anchoring on one would re-align the `RRULE`), and so do
|
||||||
|
* recurring subtasks, whose copies would pile up under the parent.
|
||||||
|
*/
|
||||||
|
private fun advance(master: TaskEntity, anchor: Instant, override: TaskEntity?, close: (TaskEntity) -> TaskEntity): Boolean {
|
||||||
|
val rule = master.rrule?.takeIf { it.isNotBlank() } ?: return false
|
||||||
|
if (!master.rdate.isNullOrBlank() || master.parentId != null) return false
|
||||||
|
val spec = master.recurrenceSpec() ?: return false
|
||||||
|
val overrides = tasks.overrides(master.id).associateBy { it.recurrenceId?.epochSeconds }
|
||||||
|
fun closedAt(at: Instant) = overrides[at.epochSeconds]?.isClosed == true
|
||||||
|
val leading = expandFrom(spec, maxOccurrences = overrides.values.count { it.isClosed } + 2)
|
||||||
|
val index = leading.indexOfFirst { sameSecond(it, anchor) }
|
||||||
|
if (index < 0 || leading.take(index).any { !closedAt(it) }) return false
|
||||||
|
var nextIndex = index + 1
|
||||||
|
while (nextIndex < leading.size && closedAt(leading[nextIndex])) nextIndex++
|
||||||
|
val next = leading.getOrNull(nextIndex) ?: return true.also { closeSeries(master, override, close) }
|
||||||
|
|
||||||
|
val now = clock.now()
|
||||||
|
val zone = master.timezone ?: zone()
|
||||||
|
val detached = (leading.take(index) + leading.subList(index + 1, nextIndex)).map { at ->
|
||||||
|
at to detach(overrides.getValue(at.epochSeconds), now)
|
||||||
|
}
|
||||||
|
// Expansion drops the anchor's sub-second part; the series keeps it.
|
||||||
|
val newAnchor = next + (spec.anchor - wholeSecond(spec.anchor))
|
||||||
|
val spent = spentBefore(spec, next)
|
||||||
|
val (start, due) = occurrenceTimes(master, newAnchor)
|
||||||
|
val ruleAfter = TaskFormWriter.remainingCount(rule, spent)
|
||||||
|
tasks.update(
|
||||||
|
master.copy(
|
||||||
|
dtstart = start,
|
||||||
|
due = due,
|
||||||
|
rrule = ruleAfter,
|
||||||
|
exdate = TaskFormWriter.valuesFrom(master.exdate, newAnchor, zone),
|
||||||
|
status = TaskStatus.NEEDS_ACTION,
|
||||||
|
percentComplete = null,
|
||||||
|
completedAt = null,
|
||||||
|
lastModified = now,
|
||||||
|
isDirty = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val copyId = if (override != null) {
|
||||||
|
detach(override, now, close)
|
||||||
|
} else {
|
||||||
|
val (copyStart, copyDue) = occurrenceTimes(master, anchor)
|
||||||
|
val id = tasks.insert(close(asCopy(master, now).copy(dtstart = copyStart, due = copyDue)))
|
||||||
|
alarms.copyToTask(master.id, id, sameResource = false)
|
||||||
|
id
|
||||||
|
}
|
||||||
|
advanced.at(master.id, wholeSecond(anchor))?.let { advanced.delete(it.copyId) }
|
||||||
|
advanced.pruneTickedBefore(master.id, now - RECORD_HORIZON)
|
||||||
|
advanced.insert(
|
||||||
|
AdvancedOccurrenceEntity(
|
||||||
|
copyId = copyId,
|
||||||
|
masterId = master.id,
|
||||||
|
anchor = anchor,
|
||||||
|
advancedTo = newAnchor,
|
||||||
|
priorDtstart = master.dtstart,
|
||||||
|
priorDue = master.due,
|
||||||
|
priorStatus = master.status,
|
||||||
|
priorPercentComplete = master.percentComplete,
|
||||||
|
priorCompletedAt = master.completedAt,
|
||||||
|
spent = spent,
|
||||||
|
ruleAfter = ruleAfter,
|
||||||
|
droppedExdates = AdvancedOccurrenceEntity.encode(TaskFormWriter.instantsBefore(master.exdate, newAnchor, zone)),
|
||||||
|
fromOverride = override != null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
advanced.insertDetached(detached.map { (at, id) -> AdvancedDetachedEntity(copyId = copyId, anchor = at, taskId = id) })
|
||||||
|
writes.onWritten(setOf(master.listId))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Occurrences `COUNT` has spent before [cut]: counted before `EXDATE` (RFC 5545 §3.8.5.3). */
|
||||||
|
private fun spentBefore(spec: RecurrenceSpec, cut: Instant): Int = RecurrenceExpander.expand(
|
||||||
|
spec.copy(exdate = null),
|
||||||
|
ExpansionWindow(from = wholeSecond(spec.anchor), until = cut, maxOccurrences = SPLIT_COUNT_CEILING),
|
||||||
|
).size
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns the override [row] into a task of its own, in place so its id keeps
|
||||||
|
* working: a new UID, unlinked from the series, a resource of its own — and
|
||||||
|
* so its alarms are fresh copies, not the series' ones.
|
||||||
|
*/
|
||||||
|
private fun detach(row: TaskEntity, now: Instant, close: (TaskEntity) -> TaskEntity = { it }): Long {
|
||||||
|
tasks.update(close(asCopy(row, now).copy(id = row.id)))
|
||||||
|
refreshAlarms(row.id)
|
||||||
|
return row.id
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshAlarms(taskId: Long) {
|
||||||
|
val current = alarms.forTask(taskId)
|
||||||
|
alarms.deleteForTask(taskId)
|
||||||
|
current.forEach { alarms.insert(it.freshCopy(taskId)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [source] as a standalone task: no series fields, never uploaded. */
|
||||||
|
private fun asCopy(source: TaskEntity, now: Instant): TaskEntity = source.copy(
|
||||||
|
id = 0,
|
||||||
|
uid = UUID.randomUUID().toString(),
|
||||||
|
masterId = null,
|
||||||
|
recurrenceId = null,
|
||||||
|
href = null,
|
||||||
|
etag = null,
|
||||||
|
rrule = null,
|
||||||
|
rdate = null,
|
||||||
|
exdate = null,
|
||||||
|
createdAt = now,
|
||||||
|
sequence = 0,
|
||||||
|
isDeleted = false,
|
||||||
|
isDirty = true,
|
||||||
|
unknownProperties = seriesFreeText(source.unknownProperties),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Undoes [advance] for the copy [row] while the series still sits where it
|
||||||
|
* moved it under the same rule, and the copy is still just that — same list,
|
||||||
|
* nothing filed under it. The anchor goes back, `COUNT` gets back what it
|
||||||
|
* spent, and the dropped `EXDATE`s return in the list's own shape; any other
|
||||||
|
* edit made since stays. The occurrence takes [reopened]'s state: as a plain
|
||||||
|
* occurrence again, or as an override where that state is its own. The
|
||||||
|
* occurrences detached with it become overrides again, or are excluded where
|
||||||
|
* their row is gone or has a life of its own. False when it cannot, so the
|
||||||
|
* caller reopens the copy as a task of its own.
|
||||||
|
*/
|
||||||
|
private fun reopenCopy(row: TaskEntity, reopened: TaskEntity, record: AdvancedOccurrenceEntity): Boolean {
|
||||||
|
val master = tasks.entity(record.masterId)?.takeIf {
|
||||||
|
!it.isDeleted && !it.isClosed && row.listId == it.listId && sameRule(it.rrule, record.ruleAfter) &&
|
||||||
|
it.recurrenceAnchor?.let { a -> sameSecond(a, record.advancedTo) } == true
|
||||||
|
}
|
||||||
|
if (master == null || tasks.subtasks(row.id).isNotEmpty()) return false
|
||||||
|
val now = clock.now()
|
||||||
|
val zone = master.timezone ?: zone()
|
||||||
|
val synced = lists.entity(master.listId)?.accountId != null
|
||||||
|
val (start, due) = occurrenceTimes(master, record.priorDtstart ?: record.priorDue ?: record.anchor)
|
||||||
|
var back = master.copy(
|
||||||
|
dtstart = start,
|
||||||
|
due = due,
|
||||||
|
rrule = master.rrule?.let { TaskFormWriter.remainingCount(it, -record.spent) },
|
||||||
|
status = if (master.status == TaskStatus.NEEDS_ACTION) record.priorStatus else master.status,
|
||||||
|
percentComplete = master.percentComplete ?: record.priorPercentComplete,
|
||||||
|
completedAt = master.completedAt ?: record.priorCompletedAt,
|
||||||
|
lastModified = now,
|
||||||
|
isDirty = true,
|
||||||
|
)
|
||||||
|
record.dropped.forEach { back = TaskFormWriter.excepting(back, it, now, zone, dirty = synced) }
|
||||||
|
// A detached row given a parent or subtasks since has a life of its own, and stays out.
|
||||||
|
fun returns(task: TaskEntity?) = task != null && !task.isDeleted && task.listId == master.listId &&
|
||||||
|
task.parentId == master.parentId && tasks.subtasks(task.id).isEmpty()
|
||||||
|
val detached = advanced.detachedFor(row.id).map { it.anchor to it.taskId?.let(tasks::entity) }
|
||||||
|
detached.forEach { (at, task) -> if (!returns(task)) back = TaskFormWriter.excepting(back, at, now, zone, dirty = synced) }
|
||||||
|
tasks.update(back)
|
||||||
|
detached.forEach { (at, task) -> if (returns(task)) toOverride(task!!, back, at, now) }
|
||||||
|
advanced.delete(row.id)
|
||||||
|
val plain = reopened.status == TaskStatus.NEEDS_ACTION && reopened.percentComplete == null
|
||||||
|
if (record.fromOverride || !plain) toOverride(reopened, back, record.anchor, now)
|
||||||
|
else deleteTask(row.id)
|
||||||
|
writes.onWritten(setOf(master.listId))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The same `RRULE` however a server re-serialised it: parts in any order and case. */
|
||||||
|
private fun sameRule(a: String?, b: String?): Boolean {
|
||||||
|
fun parts(rule: String?) = rule?.trim()?.removePrefix("RRULE:")?.split(';')
|
||||||
|
?.map { it.trim().uppercase() }?.filter { it.isNotEmpty() }?.toSet()
|
||||||
|
return parts(a) == parts(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [row], a detached occurrence, back as [master]'s override for [anchor]. */
|
||||||
|
private fun toOverride(row: TaskEntity, master: TaskEntity, anchor: Instant, now: Instant) {
|
||||||
|
val back = row.copy(
|
||||||
|
uid = master.uid,
|
||||||
|
masterId = master.id,
|
||||||
|
recurrenceId = anchor,
|
||||||
|
href = null,
|
||||||
|
etag = null,
|
||||||
|
isDirty = true,
|
||||||
|
lastModified = now,
|
||||||
|
unknownProperties = seriesFreeText(row.unknownProperties),
|
||||||
|
)
|
||||||
|
if (row.href == null) {
|
||||||
|
tasks.update(back)
|
||||||
|
refreshAlarms(row.id)
|
||||||
|
} else {
|
||||||
|
// Already uploaded as a resource of its own: that one goes, a fresh override comes.
|
||||||
|
val id = tasks.insert(back.copy(id = 0))
|
||||||
|
alarms.copyToTask(row.id, id, sameResource = false)
|
||||||
|
deleteTask(row.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The copy [advance] left for [master]'s occurrence at [anchor], if it is still there. */
|
||||||
|
private fun copyAt(master: TaskEntity, anchor: Instant): TaskEntity? =
|
||||||
|
advanced.at(master.id, wholeSecond(anchor))?.let { tasks.entity(it.copyId) }?.takeUnless { it.isDeleted }
|
||||||
|
|
||||||
|
/** The series' occurrences from its anchor on, at most [maxOccurrences]. */
|
||||||
|
private fun expandFrom(spec: RecurrenceSpec, maxOccurrences: Int): List<Instant> {
|
||||||
|
val from = wholeSecond(spec.anchor)
|
||||||
|
return RecurrenceExpander.expand(spec, ExpansionWindow(from = from, until = from + SERIES_LOOKAHEAD, maxOccurrences = maxOccurrences))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The residue without the series' own lines, as a new resource must not
|
||||||
|
* carry them. Lines inside a nested component (a `VALARM`'s `UID`) are its
|
||||||
|
* own and stay.
|
||||||
|
*/
|
||||||
|
private fun seriesFreeText(unknown: String?): String? {
|
||||||
|
var depth = 0
|
||||||
|
return unknown?.lines()?.filter { line ->
|
||||||
|
when {
|
||||||
|
line.startsWith("BEGIN:", ignoreCase = true) -> { depth++; true }
|
||||||
|
line.startsWith("END:", ignoreCase = true) -> { depth--; true }
|
||||||
|
else -> depth > 0 || (line.isNotBlank() && SERIES_LINES.none { line.startsWith(it, ignoreCase = true) })
|
||||||
|
}
|
||||||
|
}?.joinToString("\r\n")?.ifBlank { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies [write] to [override], or to a new one forked from [master] for [occurrenceStart]. */
|
||||||
|
private fun writeInstance(master: TaskEntity, override: TaskEntity?, occurrenceStart: Instant, write: (TaskEntity) -> TaskEntity) {
|
||||||
|
if (override != null) {
|
||||||
|
tasks.update(write(override))
|
||||||
|
} else {
|
||||||
|
val (start, due) = occurrenceTimes(master, occurrenceStart)
|
||||||
|
val id = tasks.insert(write(newOverride(master, master.id, occurrenceStart, start, due)))
|
||||||
|
alarms.copyToTask(master.id, id, sameResource = true)
|
||||||
}
|
}
|
||||||
val (start, due) = occurrenceTimes(master, occurrenceStart)
|
|
||||||
val id = tasks.insert(write(newOverride(master, taskId, occurrenceStart, start, due)))
|
|
||||||
alarms.copyToTask(taskId, id, sameResource = true)
|
|
||||||
writes.onWritten(setOf(master.listId))
|
writes.onWritten(setOf(master.listId))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -607,7 +915,7 @@ class RoomTasksDataSource @Inject constructor(
|
|||||||
if (lists.exists(edited.listId) == 0) throw TaskWriteFailedException("move task: no list ${edited.listId}")
|
if (lists.exists(edited.listId) == 0) throw TaskWriteFailedException("move task: no list ${edited.listId}")
|
||||||
move(current, edited)
|
move(current, edited)
|
||||||
}
|
}
|
||||||
else -> tasks.update(edited)
|
else -> saveRow(current, edited)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
writes.onWritten(setOf(current.listId, edited.listId))
|
writes.onWritten(setOf(current.listId, edited.listId))
|
||||||
@@ -650,8 +958,12 @@ class RoomTasksDataSource @Inject constructor(
|
|||||||
|
|
||||||
// --- observation ----------------------------------------------------------
|
// --- observation ----------------------------------------------------------
|
||||||
|
|
||||||
override fun registerObserver(onChange: () -> Unit): AutoCloseable {
|
override fun registerObserver(onChange: () -> Unit): AutoCloseable = observe(OBSERVED_TABLES, onChange)
|
||||||
val observer = object : InvalidationTracker.Observer(OBSERVED_TABLES) {
|
|
||||||
|
override fun registerTaskObserver(onChange: () -> Unit): AutoCloseable = observe(TASK_TABLES, onChange)
|
||||||
|
|
||||||
|
private fun observe(tables: Array<String>, onChange: () -> Unit): AutoCloseable {
|
||||||
|
val observer = object : InvalidationTracker.Observer(tables) {
|
||||||
override fun onInvalidated(tables: Set<String>) = onChange()
|
override fun onInvalidated(tables: Set<String>) = onChange()
|
||||||
}
|
}
|
||||||
database.invalidationTracker.addObserver(observer)
|
database.invalidationTracker.addObserver(observer)
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ interface TaskDao {
|
|||||||
@Query(
|
@Query(
|
||||||
"""
|
"""
|
||||||
SELECT t.*, l.name AS list_name, l.color AS list_color, l.is_read_only AS list_read_only,
|
SELECT t.*, l.name AS list_name, l.color AS list_color, l.is_read_only AS list_read_only,
|
||||||
a.display_name AS account_display_name
|
a.display_name AS account_display_name, a.label AS account_label
|
||||||
FROM tasks t
|
FROM tasks t
|
||||||
JOIN task_lists l ON l.id = t.list_id
|
JOIN task_lists l ON l.id = t.list_id
|
||||||
LEFT JOIN accounts a ON a.id = l.account_id
|
LEFT JOIN accounts a ON a.id = l.account_id
|
||||||
@@ -44,7 +44,7 @@ interface TaskDao {
|
|||||||
@Query(
|
@Query(
|
||||||
"""
|
"""
|
||||||
SELECT t.*, l.name AS list_name, l.color AS list_color, l.is_read_only AS list_read_only,
|
SELECT t.*, l.name AS list_name, l.color AS list_color, l.is_read_only AS list_read_only,
|
||||||
a.display_name AS account_display_name
|
a.display_name AS account_display_name, a.label AS account_label
|
||||||
FROM tasks t
|
FROM tasks t
|
||||||
JOIN task_lists l ON l.id = t.list_id
|
JOIN task_lists l ON l.id = t.list_id
|
||||||
LEFT JOIN accounts a ON a.id = l.account_id
|
LEFT JOIN accounts a ON a.id = l.account_id
|
||||||
@@ -56,7 +56,7 @@ interface TaskDao {
|
|||||||
@Query(
|
@Query(
|
||||||
"""
|
"""
|
||||||
SELECT t.*, l.name AS list_name, l.color AS list_color, l.is_read_only AS list_read_only,
|
SELECT t.*, l.name AS list_name, l.color AS list_color, l.is_read_only AS list_read_only,
|
||||||
a.display_name AS account_display_name
|
a.display_name AS account_display_name, a.label AS account_label
|
||||||
FROM tasks t
|
FROM tasks t
|
||||||
JOIN task_lists l ON l.id = t.list_id
|
JOIN task_lists l ON l.id = t.list_id
|
||||||
LEFT JOIN accounts a ON a.id = l.account_id
|
LEFT JOIN accounts a ON a.id = l.account_id
|
||||||
@@ -199,4 +199,8 @@ interface TaskDao {
|
|||||||
*/
|
*/
|
||||||
@Query("UPDATE tasks SET href = :href, etag = :etag, is_dirty = 0 WHERE id IN (:taskIds)")
|
@Query("UPDATE tasks SET href = :href, etag = :etag, is_dirty = 0 WHERE id IN (:taskIds)")
|
||||||
fun markSynced(taskIds: List<Long>, href: String?, etag: String?): Int
|
fun markSynced(taskIds: List<Long>, href: String?, etag: String?): Int
|
||||||
|
|
||||||
|
/** Sets `is_dirty` again on [taskId] when it was written after [seen], its `last_modified` when uploaded. */
|
||||||
|
@Query("UPDATE tasks SET is_dirty = 1 WHERE id = :taskId AND last_modified IS NOT :seen")
|
||||||
|
fun dirtyIfChangedSince(taskId: Long, seen: Instant?)
|
||||||
}
|
}
|
||||||
@@ -120,18 +120,25 @@ object TaskFormWriter {
|
|||||||
fun valuesBefore(values: String?, cut: Instant, zone: String): String? =
|
fun valuesBefore(values: String?, cut: Instant, zone: String): String? =
|
||||||
mapValues(values, zone) { it.takeIf { at -> at < cut } }
|
mapValues(values, zone) { it.takeIf { at -> at < cut } }
|
||||||
|
|
||||||
|
/** The instants of an `EXDATE`/`RDATE` list before [cut]; values it cannot read are left out. */
|
||||||
|
fun instantsBefore(values: String?, cut: Instant, zone: String): List<Instant> =
|
||||||
|
parsed(values, zone).mapNotNull { it.second }.filter { it < cut }
|
||||||
|
|
||||||
/** The values of an `EXDATE`/`RDATE` list at or after [cut]. */
|
/** The values of an `EXDATE`/`RDATE` list at or after [cut]. */
|
||||||
fun valuesFrom(values: String?, cut: Instant, zone: String): String? =
|
fun valuesFrom(values: String?, cut: Instant, zone: String): String? =
|
||||||
mapValues(values, zone) { it.takeIf { at -> at >= cut } }
|
mapValues(values, zone) { it.takeIf { at -> at >= cut } }
|
||||||
|
|
||||||
private fun mapValues(values: String?, zone: String, map: (Instant) -> Instant?): String? {
|
private fun mapValues(values: String?, zone: String, map: (Instant) -> Instant?): String? {
|
||||||
val list = values?.split(',')?.map(String::trim)?.filter(String::isNotEmpty) ?: return values
|
if (values == null) return null
|
||||||
return list.mapNotNull { raw ->
|
return parsed(values, zone).mapNotNull { (raw, at) ->
|
||||||
val at = readValue(raw, zone) ?: return@mapNotNull raw
|
if (at == null) raw else map(at)?.let { writeValueLike(raw, it, zone) }
|
||||||
map(at)?.let { writeValueLike(raw, it, zone) }
|
|
||||||
}.joinToString(",").ifEmpty { null }
|
}.joinToString(",").ifEmpty { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Each value of an `EXDATE`/`RDATE` list with its instant, or null where it cannot be read. */
|
||||||
|
private fun parsed(values: String?, zone: String): List<Pair<String, Instant?>> =
|
||||||
|
values?.split(',')?.map(String::trim)?.filter(String::isNotEmpty)?.map { it to readValue(it, zone) }.orEmpty()
|
||||||
|
|
||||||
private fun readValue(raw: String, zone: String): Instant? = runCatching {
|
private fun readValue(raw: String, zone: String): Instant? = runCatching {
|
||||||
val z = java.time.ZoneId.of(zone)
|
val z = java.time.ZoneId.of(zone)
|
||||||
val instant = when {
|
val instant = when {
|
||||||
|
|||||||
@@ -16,22 +16,26 @@ interface TaskListDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"""
|
"""
|
||||||
SELECT l.*, a.display_name AS account_display_name
|
SELECT l.*, a.display_name AS account_display_name, a.label AS account_label
|
||||||
FROM task_lists l LEFT JOIN accounts a ON a.id = l.account_id
|
FROM task_lists l LEFT JOIN accounts a ON a.id = l.account_id
|
||||||
ORDER BY a.display_name, l.sort_order, l.name
|
ORDER BY a.display_name COLLATE NOCASE, l.sort_order, l.name COLLATE NOCASE, l.name
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
fun lists(): List<TaskListRow>
|
fun lists(): List<TaskListRow>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"""
|
"""
|
||||||
SELECT l.*, a.display_name AS account_display_name
|
SELECT l.*, a.display_name AS account_display_name, a.label AS account_label
|
||||||
FROM task_lists l LEFT JOIN accounts a ON a.id = l.account_id
|
FROM task_lists l LEFT JOIN accounts a ON a.id = l.account_id
|
||||||
WHERE l.id = :listId
|
WHERE l.id = :listId
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
fun list(listId: Long): TaskListRow?
|
fun list(listId: Long): TaskListRow?
|
||||||
|
|
||||||
|
/** The highest position among device-only lists, -1 when there are none. */
|
||||||
|
@Query("SELECT COALESCE(MAX(sort_order), -1) FROM task_lists WHERE account_id IS NULL")
|
||||||
|
fun lastLocalSortOrder(): Int
|
||||||
|
|
||||||
@Query("SELECT * FROM task_lists WHERE id = :listId")
|
@Query("SELECT * FROM task_lists WHERE id = :listId")
|
||||||
fun entity(listId: Long): TaskListEntity?
|
fun entity(listId: Long): TaskListEntity?
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import java.util.UUID
|
|||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agendula's own task store. Four tables, designed from
|
* Agendula's own task store. Six tables, designed from
|
||||||
* what the app actually reads and writes plus RFC 5545's `VTODO`.
|
* what the app actually reads and writes plus RFC 5545's `VTODO`.
|
||||||
*
|
*
|
||||||
* Schemas are exported to `app/schemas/` and committed, so a future version can
|
* Schemas are exported to `app/schemas/` and committed, so a future version can
|
||||||
@@ -21,8 +21,10 @@ import kotlin.time.Instant
|
|||||||
TaskListEntity::class,
|
TaskListEntity::class,
|
||||||
TaskEntity::class,
|
TaskEntity::class,
|
||||||
TaskAlarmEntity::class,
|
TaskAlarmEntity::class,
|
||||||
|
AdvancedOccurrenceEntity::class,
|
||||||
|
AdvancedDetachedEntity::class,
|
||||||
],
|
],
|
||||||
version = 3,
|
version = 5,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
@TypeConverters(Converters::class)
|
@TypeConverters(Converters::class)
|
||||||
@@ -31,6 +33,7 @@ abstract class TasksDatabase : RoomDatabase() {
|
|||||||
abstract fun tasks(): TaskDao
|
abstract fun tasks(): TaskDao
|
||||||
abstract fun alarms(): TaskAlarmDao
|
abstract fun alarms(): TaskAlarmDao
|
||||||
abstract fun accounts(): AccountDao
|
abstract fun accounts(): AccountDao
|
||||||
|
abstract fun advancedOccurrences(): AdvancedOccurrenceDao
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val NAME = "agendula-tasks.db"
|
const val NAME = "agendula-tasks.db"
|
||||||
@@ -173,5 +176,33 @@ abstract class TasksDatabase : RoomDatabase() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** v4 lets the user name an account (#105). */
|
||||||
|
val MIGRATION_3_4 = object : Migration(3, 4) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("ALTER TABLE accounts ADD COLUMN label TEXT")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** v5 remembers how ticking an occurrence moved its series, so reopening it can move it back. */
|
||||||
|
val MIGRATION_4_5 = object : Migration(4, 5) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL(
|
||||||
|
"CREATE TABLE IF NOT EXISTS `advanced_occurrences` (`copy_id` INTEGER NOT NULL, `master_id` INTEGER NOT NULL, `anchor` INTEGER NOT NULL, `advanced_to` INTEGER NOT NULL, `prior_dtstart` INTEGER, `prior_due` INTEGER, `prior_status` INTEGER NOT NULL, `prior_percent_complete` INTEGER, `prior_completed_at` INTEGER, `spent` INTEGER NOT NULL, `rule_after` TEXT, `dropped_exdates` TEXT NOT NULL DEFAULT '', `from_override` INTEGER NOT NULL, `undoable` INTEGER NOT NULL DEFAULT 1, `settled` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`copy_id`), FOREIGN KEY(`copy_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`master_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
)
|
||||||
|
db.execSQL(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_advanced_occurrences_master_id_anchor` ON `advanced_occurrences` (`master_id`, `anchor`)",
|
||||||
|
)
|
||||||
|
db.execSQL(
|
||||||
|
"CREATE TABLE IF NOT EXISTS `advanced_detached` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `copy_id` INTEGER NOT NULL, `anchor` INTEGER NOT NULL, `task_id` INTEGER, FOREIGN KEY(`copy_id`) REFERENCES `advanced_occurrences`(`copy_id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`task_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
|
||||||
|
)
|
||||||
|
db.execSQL(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_advanced_detached_copy_id` ON `advanced_detached` (`copy_id`)",
|
||||||
|
)
|
||||||
|
db.execSQL(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_advanced_detached_task_id` ON `advanced_detached` (`task_id`)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,7 +159,9 @@ class ExternalImport @Inject constructor(
|
|||||||
val taskDao = database.tasks()
|
val taskDao = database.tasks()
|
||||||
val alarmDao = database.alarms()
|
val alarmDao = database.alarms()
|
||||||
|
|
||||||
val listIds = snapshot.lists.associate { list ->
|
// After the device-only lists already there, which the copies join.
|
||||||
|
val after = listDao.lastLocalSortOrder() + 1
|
||||||
|
val listIds = snapshot.lists.withIndex().associate { (position, list) ->
|
||||||
list.id to listDao.insert(
|
list.id to listDao.insert(
|
||||||
TaskListEntity(
|
TaskListEntity(
|
||||||
name = list.name,
|
name = list.name,
|
||||||
@@ -168,6 +170,7 @@ class ExternalImport @Inject constructor(
|
|||||||
isVisible = list.isVisible,
|
isVisible = list.isVisible,
|
||||||
isSynced = false,
|
isSynced = false,
|
||||||
owner = list.owner,
|
owner = list.owner,
|
||||||
|
sortOrder = after + position,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ data class TaskList(
|
|||||||
|
|
||||||
enum class TaskStatus { NEEDS_ACTION, IN_PROCESS, COMPLETED, CANCELLED }
|
enum class TaskStatus { NEEDS_ACTION, IN_PROCESS, COMPLETED, CANCELLED }
|
||||||
|
|
||||||
|
/** Completed or cancelled. */
|
||||||
|
val TaskStatus.isClosed: Boolean get() = this == TaskStatus.COMPLETED || this == TaskStatus.CANCELLED
|
||||||
|
|
||||||
/** iCalendar priority, bucketed for the UI. */
|
/** iCalendar priority, bucketed for the UI. */
|
||||||
enum class Priority { NONE, LOW, MEDIUM, HIGH }
|
enum class Priority { NONE, LOW, MEDIUM, HIGH }
|
||||||
|
|
||||||
@@ -111,7 +114,7 @@ data class Task(
|
|||||||
val isOccurrence: Boolean get() = seriesId != null && occurrenceStart != null
|
val isOccurrence: Boolean get() = seriesId != null && occurrenceStart != null
|
||||||
|
|
||||||
val isCompleted: Boolean get() = status == TaskStatus.COMPLETED
|
val isCompleted: Boolean get() = status == TaskStatus.COMPLETED
|
||||||
val isClosed: Boolean get() = status == TaskStatus.COMPLETED || status == TaskStatus.CANCELLED
|
val isClosed: Boolean get() = status.isClosed
|
||||||
val isSubtask: Boolean get() = parentId != null && parentId > 0
|
val isSubtask: Boolean get() = parentId != null && parentId > 0
|
||||||
/** The task's own colour if set, else the list colour. */
|
/** The task's own colour if set, else the list colour. */
|
||||||
val effectiveColor: Int get() = taskColor ?: listColor
|
val effectiveColor: Int get() = taskColor ?: listColor
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ fun dueWindowOf(
|
|||||||
todayEnd: Instant,
|
todayEnd: Instant,
|
||||||
zone: ZoneId = ZoneId.systemDefault(),
|
zone: ZoneId = ZoneId.systemDefault(),
|
||||||
): DueWindow {
|
): DueWindow {
|
||||||
val due = task.due?.let { if (task.isAllDay) it.rebasedForAllDay(allDay = false, zone = zone) else it }
|
val due = task.wallClock(task.due, zone)
|
||||||
return when {
|
return when {
|
||||||
due == null -> DueWindow.NO_DATE
|
due == null -> DueWindow.NO_DATE
|
||||||
due < todayStart -> DueWindow.OVERDUE
|
due < todayStart -> DueWindow.OVERDUE
|
||||||
@@ -68,6 +68,10 @@ fun dueWindowOf(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** [at], one of this task's dates, as a local instant: an all-day date is read as its calendar date. */
|
||||||
|
fun Task.wallClock(at: Instant?, zone: ZoneId = ZoneId.systemDefault()): Instant? =
|
||||||
|
at?.let { if (isAllDay) it.rebasedForAllDay(allDay = false, zone = zone) else it }
|
||||||
|
|
||||||
/** What a task created from a [SavedFilter] starts with, so it lands in that filter. */
|
/** What a task created from a [SavedFilter] starts with, so it lands in that filter. */
|
||||||
data class NewTaskPreset(
|
data class NewTaskPreset(
|
||||||
val listIds: Set<Long> = emptySet(),
|
val listIds: Set<Long> = emptySet(),
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
package de.jeanlucmakiola.agendula.domain
|
package de.jeanlucmakiola.agendula.domain
|
||||||
|
|
||||||
|
import java.time.ZoneId
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/** The built-in "smart" lists, computed from due dates rather than membership. */
|
/**
|
||||||
|
* The built-in "smart" lists, computed from dates rather than membership. All
|
||||||
|
* but [COMPLETED] hold open tasks, except [TODAY], which also keeps the ones
|
||||||
|
* completed today (see [TaskFiltering.isToday]).
|
||||||
|
*/
|
||||||
enum class SmartList { ALL, TODAY, UPCOMING, OVERDUE, NO_DATE, COMPLETED }
|
enum class SmartList { ALL, TODAY, UPCOMING, OVERDUE, NO_DATE, COMPLETED }
|
||||||
|
|
||||||
/** What a task screen is showing: one real list, a smart list, or a tag. */
|
/** What a task screen is showing: one real list, a smart list, or a tag. */
|
||||||
@@ -40,6 +45,20 @@ object TaskFiltering {
|
|||||||
is TaskFilter.Saved -> saved?.takeIf { it.id == filter.id }?.matches(task, todayStart, todayEnd) ?: false
|
is TaskFilter.Saved -> saved?.takeIf { it.id == filter.id }?.matches(task, todayStart, todayEnd) ?: false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether [task] belongs in Today. An open task does when it is due today, or
|
||||||
|
* starts today without being overdue; a completed one when it was completed today.
|
||||||
|
*/
|
||||||
|
fun isToday(task: Task, todayStart: Instant, todayEnd: Instant, zone: ZoneId = ZoneId.systemDefault()): Boolean {
|
||||||
|
fun inToday(at: Instant?) = at != null && at >= todayStart && at < todayEnd
|
||||||
|
if (task.isClosed) return task.isCompleted && inToday(task.completedAt)
|
||||||
|
return when (dueWindowOf(task, todayStart, todayEnd, zone)) {
|
||||||
|
DueWindow.TODAY -> true
|
||||||
|
DueWindow.OVERDUE -> false
|
||||||
|
DueWindow.UPCOMING, DueWindow.NO_DATE -> inToday(task.wallClock(task.start, zone))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Whether [task] carries every one of [tags]; an empty set matches everything. */
|
/** Whether [task] carries every one of [tags]; an empty set matches everything. */
|
||||||
fun hasAllTags(task: Task, tags: Set<String>): Boolean = task.tags.containsAll(tags)
|
fun hasAllTags(task: Task, tags: Set<String>): Boolean = task.tags.containsAll(tags)
|
||||||
|
|
||||||
@@ -49,7 +68,7 @@ object TaskFiltering {
|
|||||||
SmartList.ALL -> !task.isClosed
|
SmartList.ALL -> !task.isClosed
|
||||||
SmartList.NO_DATE -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.NO_DATE
|
SmartList.NO_DATE -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.NO_DATE
|
||||||
SmartList.OVERDUE -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.OVERDUE
|
SmartList.OVERDUE -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.OVERDUE
|
||||||
SmartList.TODAY -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.TODAY
|
SmartList.TODAY -> isToday(task, todayStart, todayEnd)
|
||||||
SmartList.UPCOMING -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.UPCOMING
|
SmartList.UPCOMING -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.UPCOMING
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16,19 +16,22 @@ data class SectionedTasks(val section: TaskSection, val tasks: List<Task>)
|
|||||||
*
|
*
|
||||||
* [todayStart] is local midnight today and [todayEnd] local midnight tomorrow
|
* [todayStart] is local midnight today and [todayEnd] local midnight tomorrow
|
||||||
* (compute with [DayWindow]). Only non-empty sections are returned, in the
|
* (compute with [DayWindow]). Only non-empty sections are returned, in the
|
||||||
* fixed order Overdue → Today → Upcoming → No date → Completed.
|
* fixed order Overdue → Today → Upcoming → No date → Completed. In the Today
|
||||||
|
* list ([todayList]) every open task sits under Today, as it may be there by its
|
||||||
|
* start date alone.
|
||||||
*/
|
*/
|
||||||
object TaskSections {
|
object TaskSections {
|
||||||
|
|
||||||
fun of(tasks: List<Task>, todayStart: Instant, todayEnd: Instant): List<SectionedTasks> {
|
fun of(tasks: List<Task>, todayStart: Instant, todayEnd: Instant, todayList: Boolean = false): List<SectionedTasks> {
|
||||||
val bySection = tasks.groupBy { sectionOf(it, todayStart, todayEnd) }
|
val bySection = tasks.groupBy { sectionOf(it, todayStart, todayEnd, todayList) }
|
||||||
return TaskSection.entries.mapNotNull { section ->
|
return TaskSection.entries.mapNotNull { section ->
|
||||||
bySection[section]?.takeIf { it.isNotEmpty() }?.let { SectionedTasks(section, it) }
|
bySection[section]?.takeIf { it.isNotEmpty() }?.let { SectionedTasks(section, it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sectionOf(task: Task, todayStart: Instant, todayEnd: Instant): TaskSection {
|
fun sectionOf(task: Task, todayStart: Instant, todayEnd: Instant, todayList: Boolean = false): TaskSection {
|
||||||
if (task.isCompleted) return TaskSection.COMPLETED
|
if (task.isCompleted) return TaskSection.COMPLETED
|
||||||
|
if (todayList) return TaskSection.TODAY
|
||||||
return when (dueWindowOf(task, todayStart, todayEnd)) {
|
return when (dueWindowOf(task, todayStart, todayEnd)) {
|
||||||
DueWindow.OVERDUE -> TaskSection.OVERDUE
|
DueWindow.OVERDUE -> TaskSection.OVERDUE
|
||||||
DueWindow.TODAY -> TaskSection.TODAY
|
DueWindow.TODAY -> TaskSection.TODAY
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ fun RootScreen(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
navRequest: NavRequest? = null,
|
navRequest: NavRequest? = null,
|
||||||
onNavRequestConsumed: () -> Unit = {},
|
onNavRequestConsumed: () -> Unit = {},
|
||||||
|
onReturnHome: () -> Unit = {},
|
||||||
permissionViewModel: PermissionViewModel = hiltViewModel(),
|
permissionViewModel: PermissionViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val permission by permissionViewModel.state.collectAsStateWithLifecycle()
|
val permission by permissionViewModel.state.collectAsStateWithLifecycle()
|
||||||
@@ -86,6 +87,7 @@ fun RootScreen(
|
|||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
navRequest = navRequest,
|
navRequest = navRequest,
|
||||||
onNavRequestConsumed = onNavRequestConsumed,
|
onNavRequestConsumed = onNavRequestConsumed,
|
||||||
|
onReturnHome = onReturnHome,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,6 +103,7 @@ private fun ReadyGate(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
navRequest: NavRequest? = null,
|
navRequest: NavRequest? = null,
|
||||||
onNavRequestConsumed: () -> Unit = {},
|
onNavRequestConsumed: () -> Unit = {},
|
||||||
|
onReturnHome: () -> Unit = {},
|
||||||
onboardingViewModel: OnboardingViewModel = hiltViewModel(),
|
onboardingViewModel: OnboardingViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val done by onboardingViewModel.done.collectAsStateWithLifecycle()
|
val done by onboardingViewModel.done.collectAsStateWithLifecycle()
|
||||||
@@ -113,6 +116,7 @@ private fun ReadyGate(
|
|||||||
modifier = modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface),
|
modifier = modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface),
|
||||||
navRequest = navRequest,
|
navRequest = navRequest,
|
||||||
onNavRequestConsumed = onNavRequestConsumed,
|
onNavRequestConsumed = onNavRequestConsumed,
|
||||||
|
onReturnHome = onReturnHome,
|
||||||
)
|
)
|
||||||
false -> ReadableWidth(modifier) {
|
false -> ReadableWidth(modifier) {
|
||||||
OnboardingFlow(modifier = Modifier.fillMaxSize(), viewModel = onboardingViewModel)
|
OnboardingFlow(modifier = Modifier.fillMaxSize(), viewModel = onboardingViewModel)
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.systemGestures
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.layout.positionInWindow
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
|
import androidx.compose.ui.platform.LocalWindowInfo
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
/** How far past the system back-gesture zone a horizontal swipe is still ignored. */
|
||||||
|
private val EDGE_MARGIN = 24.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Swallows horizontal swipes that start near the screen's left or right edge,
|
||||||
|
* before a swipe box below it sees them (#90). A back gesture started just
|
||||||
|
* inside the system's gesture zone otherwise reaches the app as a row swipe.
|
||||||
|
* Taps and vertical scrolls are left alone.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun Modifier.ignoringEdgeSwipes(): Modifier {
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val layoutDirection = LocalLayoutDirection.current
|
||||||
|
val windowWidth = LocalWindowInfo.current.containerSize.width
|
||||||
|
val insets = WindowInsets.systemGestures
|
||||||
|
val margin = with(density) { EDGE_MARGIN.roundToPx() }
|
||||||
|
val leftEdge = insets.getLeft(density, layoutDirection) + margin
|
||||||
|
val rightEdge = windowWidth - insets.getRight(density, layoutDirection) - margin
|
||||||
|
var offsetX by remember { mutableFloatStateOf(0f) }
|
||||||
|
return this
|
||||||
|
.onGloballyPositioned { offsetX = it.positionInWindow().x }
|
||||||
|
.pointerInput(leftEdge, rightEdge) {
|
||||||
|
val slop = viewConfiguration.touchSlop
|
||||||
|
awaitEachGesture {
|
||||||
|
val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
|
||||||
|
val x = offsetX + down.position.x
|
||||||
|
if (x > leftEdge && x < rightEdge) return@awaitEachGesture
|
||||||
|
var horizontal = false
|
||||||
|
while (true) {
|
||||||
|
val change = awaitPointerEvent(PointerEventPass.Initial).changes
|
||||||
|
.firstOrNull { it.id == down.id } ?: break
|
||||||
|
if (!horizontal) {
|
||||||
|
val delta = change.position - down.position
|
||||||
|
when {
|
||||||
|
abs(delta.x) > slop -> horizontal = true
|
||||||
|
abs(delta.y) > slop -> break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (horizontal) change.consume()
|
||||||
|
if (!change.pressed) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||||
|
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
|
||||||
|
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull down to sync every account (#73). Without an account there is nothing
|
||||||
|
* to sync, so the gesture is off. [topPadding] keeps the indicator below a top
|
||||||
|
* bar the content scrolls under.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun SyncPullBox(
|
||||||
|
available: Boolean,
|
||||||
|
syncing: Boolean,
|
||||||
|
onSync: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
topPadding: Dp = 0.dp,
|
||||||
|
content: @Composable BoxScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
if (!available) {
|
||||||
|
Box(modifier, content = content)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val state = rememberPullToRefreshState()
|
||||||
|
PullToRefreshBox(
|
||||||
|
isRefreshing = syncing,
|
||||||
|
onRefresh = onSync,
|
||||||
|
modifier = modifier,
|
||||||
|
state = state,
|
||||||
|
indicator = {
|
||||||
|
PullToRefreshDefaults.LoadingIndicator(
|
||||||
|
state = state,
|
||||||
|
isRefreshing = syncing,
|
||||||
|
modifier = Modifier.align(Alignment.TopCenter).padding(top = topPadding),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||||||
import de.jeanlucmakiola.agendula.data.prefs.DEFAULT_ALL_DAY_REMINDER_MINUTE
|
import de.jeanlucmakiola.agendula.data.prefs.DEFAULT_ALL_DAY_REMINDER_MINUTE
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.Settings
|
import de.jeanlucmakiola.agendula.data.prefs.Settings
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
|
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TaskConflictException
|
import de.jeanlucmakiola.agendula.data.tasks.TaskConflictException
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||||
import de.jeanlucmakiola.agendula.domain.Priority
|
import de.jeanlucmakiola.agendula.domain.Priority
|
||||||
@@ -109,7 +108,6 @@ data class TaskEditUiState(
|
|||||||
class TaskEditViewModel @Inject constructor(
|
class TaskEditViewModel @Inject constructor(
|
||||||
private val repository: TasksRepository,
|
private val repository: TasksRepository,
|
||||||
private val settingsPrefs: SettingsPrefs,
|
private val settingsPrefs: SettingsPrefs,
|
||||||
private val reminderScheduler: ReminderScheduler,
|
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _state = MutableStateFlow(TaskEditUiState())
|
private val _state = MutableStateFlow(TaskEditUiState())
|
||||||
@@ -149,6 +147,7 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
private var loadedReminders: List<TaskReminder> = emptyList()
|
private var loadedReminders: List<TaskReminder> = emptyList()
|
||||||
private var loadedStart: Instant? = null
|
private var loadedStart: Instant? = null
|
||||||
private var loadedDue: Instant? = null
|
private var loadedDue: Instant? = null
|
||||||
|
private var loadedAllDay = false
|
||||||
|
|
||||||
/** Start a fresh task, optionally pre-selecting a list / parent. */
|
/** Start a fresh task, optionally pre-selecting a list / parent. */
|
||||||
fun bindNew(
|
fun bindNew(
|
||||||
@@ -157,6 +156,7 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
initialTitle: String? = null,
|
initialTitle: String? = null,
|
||||||
presetTag: String? = null,
|
presetTag: String? = null,
|
||||||
presetFilterId: Long? = null,
|
presetFilterId: Long? = null,
|
||||||
|
presetDueToday: Boolean = false,
|
||||||
) {
|
) {
|
||||||
if (bound) return
|
if (bound) return
|
||||||
bound = true
|
bound = true
|
||||||
@@ -169,6 +169,7 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
|
val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
|
||||||
val preset = presetFilterId?.let { settingsPrefs.savedFilter(it).first() }
|
val preset = presetFilterId?.let { settingsPrefs.savedFilter(it).first() }
|
||||||
?.newTaskPreset(LocalDate.now())
|
?.newTaskPreset(LocalDate.now())
|
||||||
|
val presetDue = preset?.dueDate ?: LocalDate.now().takeIf { presetDueToday }
|
||||||
// A read-only or vanished default — or a widget's preset list, if it was
|
// A read-only or vanished default — or a widget's preset list, if it was
|
||||||
// since deleted or turned read-only — would give the task a list it
|
// since deleted or turned read-only — would give the task a list it
|
||||||
// cannot be saved to.
|
// cannot be saved to.
|
||||||
@@ -190,12 +191,12 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
// today's start would land after a due of today.
|
// today's start would land after a due of today.
|
||||||
start = when {
|
start = when {
|
||||||
!settings.defaultStartToday -> null
|
!settings.defaultStartToday -> null
|
||||||
preset?.dueDate != null -> allDayInstantOf(LocalDate.now())
|
presetDue != null -> allDayInstantOf(LocalDate.now())
|
||||||
else -> Clock.System.now()
|
else -> Clock.System.now()
|
||||||
},
|
},
|
||||||
// The same all-day value picking the date in the date field gives.
|
// The same all-day value picking the date in the date field gives.
|
||||||
due = preset?.dueDate?.let(::allDayInstantOf),
|
due = presetDue?.let(::allDayInstantOf),
|
||||||
isAllDay = preset?.dueDate != null,
|
isAllDay = presetDue != null,
|
||||||
priority = preset?.priority ?: Priority.NONE,
|
priority = preset?.priority ?: Priority.NONE,
|
||||||
parentId = parentId,
|
parentId = parentId,
|
||||||
lists = lists,
|
lists = lists,
|
||||||
@@ -228,6 +229,7 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
storedReminders = loadedReminders.toSet()
|
storedReminders = loadedReminders.toSet()
|
||||||
loadedStart = task.start
|
loadedStart = task.start
|
||||||
loadedDue = task.due
|
loadedDue = task.due
|
||||||
|
loadedAllDay = task.isAllDay
|
||||||
_state.value = withFields(
|
_state.value = withFields(
|
||||||
TaskEditUiState(
|
TaskEditUiState(
|
||||||
loading = false,
|
loading = false,
|
||||||
@@ -362,6 +364,7 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
toForm().copy(reminders = loadedReminders),
|
toForm().copy(reminders = loadedReminders),
|
||||||
loadedStart,
|
loadedStart,
|
||||||
loadedDue,
|
loadedDue,
|
||||||
|
loadedAllDay,
|
||||||
settings,
|
settings,
|
||||||
nowMillis(),
|
nowMillis(),
|
||||||
ZoneId.systemDefault(),
|
ZoneId.systemDefault(),
|
||||||
@@ -446,13 +449,6 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
scope = scope,
|
scope = scope,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// ⚠️ Outside the write's own result. The task is saved by this
|
|
||||||
// point, and reporting a scheduling failure as a *save* failure
|
|
||||||
// sends the user back to tap Save again — on a screen whose
|
|
||||||
// editingTaskId is still null, which creates a second task. The
|
|
||||||
// reminder is re-synced on the next data change and on launch;
|
|
||||||
// the duplicate is forever.
|
|
||||||
runCatching { reminderScheduler.sync() }
|
|
||||||
}.onSuccess {
|
}.onSuccess {
|
||||||
_state.value = _state.value.copy(saved = true, saveFailed = false, saveConflict = false)
|
_state.value = _state.value.copy(saved = true, saveFailed = false, saveConflict = false)
|
||||||
}.onFailure { error ->
|
}.onFailure { error ->
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ class ImportViewModel @Inject constructor(
|
|||||||
val result = importer.import(ready.parsed, target)
|
val result = importer.import(ready.parsed, target)
|
||||||
(chosen as? ImportChoice.Existing)?.list
|
(chosen as? ImportChoice.Existing)?.list
|
||||||
?.takeIf { it.accountId != null && result.imported > 0 }
|
?.takeIf { it.accountId != null && result.imported > 0 }
|
||||||
?.let { runCatching { syncRequests.syncNow(it.accountName) } }
|
?.let { runCatching { syncRequests.syncNow(it.accountId!!) } }
|
||||||
ImportStep.Done(
|
ImportStep.Done(
|
||||||
listName = listName,
|
listName = listName,
|
||||||
imported = result.imported,
|
imported = result.imported,
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ import kotlinx.coroutines.delay
|
|||||||
import de.jeanlucmakiola.agendula.ui.common.ListColorChip
|
import de.jeanlucmakiola.agendula.ui.common.ListColorChip
|
||||||
import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.agendula.ui.common.ShapedActionButton
|
import de.jeanlucmakiola.agendula.ui.common.ShapedActionButton
|
||||||
|
import de.jeanlucmakiola.agendula.ui.common.SyncPullBox
|
||||||
import de.jeanlucmakiola.agendula.ui.common.priorityAccent
|
import de.jeanlucmakiola.agendula.ui.common.priorityAccent
|
||||||
import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel
|
import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
@@ -122,6 +123,8 @@ fun ListsScreen(
|
|||||||
viewModel: ListsViewModel = hiltViewModel(),
|
viewModel: ListsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val syncAvailable by viewModel.syncAvailable.collectAsStateWithLifecycle()
|
||||||
|
val syncing by viewModel.syncing.collectAsStateWithLifecycle()
|
||||||
var query by rememberSaveable { mutableStateOf("") }
|
var query by rememberSaveable { mutableStateOf("") }
|
||||||
var searchActive by rememberSaveable { mutableStateOf(false) }
|
var searchActive by rememberSaveable { mutableStateOf(false) }
|
||||||
var newList by rememberSaveable { mutableStateOf(false) }
|
var newList by rememberSaveable { mutableStateOf(false) }
|
||||||
@@ -173,7 +176,12 @@ fun ListsScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
) { inner ->
|
) { inner ->
|
||||||
Box(Modifier.fillMaxSize().padding(top = inner.calculateTopPadding())) {
|
SyncPullBox(
|
||||||
|
available = syncAvailable && !searchActive,
|
||||||
|
syncing = syncing,
|
||||||
|
onSync = viewModel::syncAll,
|
||||||
|
modifier = Modifier.fillMaxSize().padding(top = inner.calculateTopPadding()),
|
||||||
|
) {
|
||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
ListsUiState.Loading -> Unit // brief; avoids a flash before first emission
|
ListsUiState.Loading -> Unit // brief; avoids a flash before first emission
|
||||||
ListsUiState.Failure ->
|
ListsUiState.Failure ->
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.ManualSync
|
||||||
import de.jeanlucmakiola.agendula.data.sync.RemoteLists
|
import de.jeanlucmakiola.agendula.data.sync.RemoteLists
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
|
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
|
||||||
@@ -105,9 +106,17 @@ private const val UPCOMING_PREVIEW = 3
|
|||||||
class ListsViewModel @Inject constructor(
|
class ListsViewModel @Inject constructor(
|
||||||
private val repository: TasksRepository,
|
private val repository: TasksRepository,
|
||||||
private val remoteLists: RemoteLists,
|
private val remoteLists: RemoteLists,
|
||||||
|
private val manualSync: ManualSync,
|
||||||
settingsPrefs: SettingsPrefs,
|
settingsPrefs: SettingsPrefs,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
/** Pull to sync (#73); off without an account. */
|
||||||
|
val syncAvailable: StateFlow<Boolean> =
|
||||||
|
manualSync.available.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||||
|
val syncing: StateFlow<Boolean> = manualSync.syncing
|
||||||
|
|
||||||
|
fun syncAll() = manualSync.syncAll()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where a new list may go: the device, then every account whose server said
|
* Where a new list may go: the device, then every account whose server said
|
||||||
* it would take one.
|
* it would take one.
|
||||||
@@ -163,14 +172,12 @@ class ListsViewModel @Inject constructor(
|
|||||||
SmartCount(SmartList.ALL, smartTopLevel.size),
|
SmartCount(SmartList.ALL, smartTopLevel.size),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Today ring: completed vs. total tasks *due today*. The numerator is the
|
// Today ring: the completed rows of the Today list against all of its rows.
|
||||||
// top-level completed tasks whose due date falls in today's window; the
|
|
||||||
// denominator adds the still-open ones (the Today smart count above).
|
|
||||||
val openToday = count(SmartList.TODAY)
|
val openToday = count(SmartList.TODAY)
|
||||||
val completedDueToday = completedTasks.count {
|
val completedToday = completedTasks.count {
|
||||||
!it.isSubtask && it.listId !in hidden && it.due != null && it.due >= todayStart && it.due < todayEnd
|
!it.isSubtask && it.listId !in hidden && TaskFiltering.isToday(it, todayStart, todayEnd)
|
||||||
}
|
}
|
||||||
val todayTotal = openToday + completedDueToday
|
val todayTotal = openToday + completedToday
|
||||||
|
|
||||||
// Upcoming preview: the next handful of open tasks due tomorrow onward,
|
// Upcoming preview: the next handful of open tasks due tomorrow onward,
|
||||||
// already sorted by the repository's default ordering.
|
// already sorted by the repository's default ordering.
|
||||||
@@ -205,7 +212,7 @@ class ListsViewModel @Inject constructor(
|
|||||||
smartCounts = smartCounts,
|
smartCounts = smartCounts,
|
||||||
filters = filters,
|
filters = filters,
|
||||||
groups = groups,
|
groups = groups,
|
||||||
todayDone = completedDueToday,
|
todayDone = completedToday,
|
||||||
todayTotal = todayTotal,
|
todayTotal = todayTotal,
|
||||||
upcoming = upcoming,
|
upcoming = upcoming,
|
||||||
allTasks = allTasks,
|
allTasks = allTasks,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.agendula.ui.navigation
|
package de.jeanlucmakiola.agendula.ui.navigation
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import androidx.compose.animation.EnterTransition
|
import androidx.compose.animation.EnterTransition
|
||||||
import androidx.compose.animation.ExitTransition
|
import androidx.compose.animation.ExitTransition
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
@@ -7,11 +8,17 @@ import androidx.compose.animation.slideInHorizontally
|
|||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import de.jeanlucmakiola.agendula.domain.SmartList
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
@@ -46,8 +53,21 @@ fun AgendulaNavHost(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
navRequest: NavRequest? = null,
|
navRequest: NavRequest? = null,
|
||||||
onNavRequestConsumed: () -> Unit = {},
|
onNavRequestConsumed: () -> Unit = {},
|
||||||
|
onReturnHome: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val nav = rememberNavController()
|
val nav = rememberNavController()
|
||||||
|
// The entry a widget, tile, shortcut or share opened; closing it leaves the app (#93).
|
||||||
|
var returnHomeEntry by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
// No public API exposes the whole back stack, and visibleEntries drops an entry covered by another.
|
||||||
|
@SuppressLint("RestrictedApi")
|
||||||
|
val backStack by nav.currentBackStack.collectAsState()
|
||||||
|
LaunchedEffect(backStack, returnHomeEntry) {
|
||||||
|
val id = returnHomeEntry ?: return@LaunchedEffect
|
||||||
|
if (backStack.none { it.id == id }) {
|
||||||
|
returnHomeEntry = null
|
||||||
|
onReturnHome()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(navRequest) {
|
LaunchedEffect(navRequest) {
|
||||||
when (navRequest) {
|
when (navRequest) {
|
||||||
@@ -59,7 +79,9 @@ fun AgendulaNavHost(
|
|||||||
is NavRequest.OpenAccount ->
|
is NavRequest.OpenAccount ->
|
||||||
nav.navigate(Dest.Settings.buildAccount(navRequest.accountId)) { launchSingleTop = true }
|
nav.navigate(Dest.Settings.buildAccount(navRequest.accountId)) { launchSingleTop = true }
|
||||||
is NavRequest.NewTask ->
|
is NavRequest.NewTask ->
|
||||||
nav.navigate(Dest.TaskEdit.buildNew(presetListId = navRequest.listId, title = navRequest.title))
|
nav.navigate(
|
||||||
|
Dest.TaskEdit.buildNew(presetListId = navRequest.listId, title = navRequest.title, dueToday = navRequest.dueToday),
|
||||||
|
)
|
||||||
is NavRequest.OpenSmart -> nav.navigate(Dest.TaskList.build(TaskFilter.Smart(navRequest.list))) {
|
is NavRequest.OpenSmart -> nav.navigate(Dest.TaskList.build(TaskFilter.Smart(navRequest.list))) {
|
||||||
launchSingleTop = true
|
launchSingleTop = true
|
||||||
}
|
}
|
||||||
@@ -71,6 +93,9 @@ fun AgendulaNavHost(
|
|||||||
}
|
}
|
||||||
is NavRequest.Import -> nav.navigate(Dest.Import.build(navRequest.uri))
|
is NavRequest.Import -> nav.navigate(Dest.Import.build(navRequest.uri))
|
||||||
}
|
}
|
||||||
|
val returnHome = (navRequest as? NavRequest.NewTask)?.returnHome == true ||
|
||||||
|
(navRequest as? NavRequest.OpenTask)?.returnHome == true
|
||||||
|
returnHomeEntry = if (returnHome) nav.currentBackStackEntry?.id else null
|
||||||
onNavRequestConsumed()
|
onNavRequestConsumed()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +166,7 @@ fun AgendulaNavHost(
|
|||||||
presetListId = (filter as? TaskFilter.OfList)?.listId,
|
presetListId = (filter as? TaskFilter.OfList)?.listId,
|
||||||
tag = (filter as? TaskFilter.Tagged)?.tag,
|
tag = (filter as? TaskFilter.Tagged)?.tag,
|
||||||
filterId = (filter as? TaskFilter.Saved)?.id,
|
filterId = (filter as? TaskFilter.Saved)?.id,
|
||||||
|
dueToday = filter == TaskFilter.Smart(SmartList.TODAY),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -212,6 +238,7 @@ fun AgendulaNavHost(
|
|||||||
val title = args?.getString(Dest.TaskEdit.ARG_TITLE)
|
val title = args?.getString(Dest.TaskEdit.ARG_TITLE)
|
||||||
val tag = args?.getString(Dest.TaskEdit.ARG_TAG)
|
val tag = args?.getString(Dest.TaskEdit.ARG_TAG)
|
||||||
val filterId = args?.getLong(Dest.TaskEdit.ARG_FILTER_ID)?.takeIf { it != Dest.NO_ID }
|
val filterId = args?.getLong(Dest.TaskEdit.ARG_FILTER_ID)?.takeIf { it != Dest.NO_ID }
|
||||||
|
val dueToday = args?.getBoolean(Dest.TaskEdit.ARG_DUE_TODAY) ?: false
|
||||||
val vm: TaskEditViewModel = hiltViewModel()
|
val vm: TaskEditViewModel = hiltViewModel()
|
||||||
LaunchedEffect(taskId, presetListId, parentId) {
|
LaunchedEffect(taskId, presetListId, parentId) {
|
||||||
if (taskId != Dest.NO_ID) {
|
if (taskId != Dest.NO_ID) {
|
||||||
@@ -223,6 +250,7 @@ fun AgendulaNavHost(
|
|||||||
initialTitle = title,
|
initialTitle = title,
|
||||||
presetTag = tag,
|
presetTag = tag,
|
||||||
presetFilterId = filterId,
|
presetFilterId = filterId,
|
||||||
|
presetDueToday = dueToday,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ object Dest {
|
|||||||
/**
|
/**
|
||||||
* The create / edit form. `taskId` set ([NO_ID] otherwise) ⇒ edit; for a new
|
* The create / edit form. `taskId` set ([NO_ID] otherwise) ⇒ edit; for a new
|
||||||
* task, `presetListId` / `parentId` optionally seed the form, and `filterId`
|
* task, `presetListId` / `parentId` optionally seed the form, and `filterId`
|
||||||
* fills it so the task lands in that saved filter.
|
* (or `dueToday`, for the Today list) fills it so the task lands in that view.
|
||||||
*/
|
*/
|
||||||
object TaskEdit {
|
object TaskEdit {
|
||||||
const val ARG_TASK_ID = "taskId"
|
const val ARG_TASK_ID = "taskId"
|
||||||
@@ -129,6 +129,7 @@ object Dest {
|
|||||||
const val ARG_TITLE = "title"
|
const val ARG_TITLE = "title"
|
||||||
const val ARG_TAG = "tag"
|
const val ARG_TAG = "tag"
|
||||||
const val ARG_FILTER_ID = "filterId"
|
const val ARG_FILTER_ID = "filterId"
|
||||||
|
const val ARG_DUE_TODAY = "dueToday"
|
||||||
const val route =
|
const val route =
|
||||||
"taskEdit?$ARG_TASK_ID={$ARG_TASK_ID}" +
|
"taskEdit?$ARG_TASK_ID={$ARG_TASK_ID}" +
|
||||||
"&$ARG_PRESET_LIST_ID={$ARG_PRESET_LIST_ID}" +
|
"&$ARG_PRESET_LIST_ID={$ARG_PRESET_LIST_ID}" +
|
||||||
@@ -136,7 +137,8 @@ object Dest {
|
|||||||
"&$ARG_OCCURRENCE={$ARG_OCCURRENCE}" +
|
"&$ARG_OCCURRENCE={$ARG_OCCURRENCE}" +
|
||||||
"&$ARG_TITLE={$ARG_TITLE}" +
|
"&$ARG_TITLE={$ARG_TITLE}" +
|
||||||
"&$ARG_TAG={$ARG_TAG}" +
|
"&$ARG_TAG={$ARG_TAG}" +
|
||||||
"&$ARG_FILTER_ID={$ARG_FILTER_ID}"
|
"&$ARG_FILTER_ID={$ARG_FILTER_ID}" +
|
||||||
|
"&$ARG_DUE_TODAY={$ARG_DUE_TODAY}"
|
||||||
|
|
||||||
val arguments = listOf(
|
val arguments = listOf(
|
||||||
navArgument(ARG_TASK_ID) { type = NavType.LongType; defaultValue = NO_ID },
|
navArgument(ARG_TASK_ID) { type = NavType.LongType; defaultValue = NO_ID },
|
||||||
@@ -146,6 +148,7 @@ object Dest {
|
|||||||
navArgument(ARG_TITLE) { type = NavType.StringType; nullable = true; defaultValue = null },
|
navArgument(ARG_TITLE) { type = NavType.StringType; nullable = true; defaultValue = null },
|
||||||
navArgument(ARG_TAG) { type = NavType.StringType; nullable = true; defaultValue = null },
|
navArgument(ARG_TAG) { type = NavType.StringType; nullable = true; defaultValue = null },
|
||||||
navArgument(ARG_FILTER_ID) { type = NavType.LongType; defaultValue = NO_ID },
|
navArgument(ARG_FILTER_ID) { type = NavType.LongType; defaultValue = NO_ID },
|
||||||
|
navArgument(ARG_DUE_TODAY) { type = NavType.BoolType; defaultValue = false },
|
||||||
)
|
)
|
||||||
|
|
||||||
fun buildEdit(taskId: Long, occurrenceStart: Long? = null): String =
|
fun buildEdit(taskId: Long, occurrenceStart: Long? = null): String =
|
||||||
@@ -158,6 +161,7 @@ object Dest {
|
|||||||
title: String? = null,
|
title: String? = null,
|
||||||
tag: String? = null,
|
tag: String? = null,
|
||||||
filterId: Long? = null,
|
filterId: Long? = null,
|
||||||
|
dueToday: Boolean = false,
|
||||||
): String {
|
): String {
|
||||||
val params = buildList {
|
val params = buildList {
|
||||||
if (presetListId != null) add("$ARG_PRESET_LIST_ID=$presetListId")
|
if (presetListId != null) add("$ARG_PRESET_LIST_ID=$presetListId")
|
||||||
@@ -165,6 +169,7 @@ object Dest {
|
|||||||
if (!title.isNullOrBlank()) add("$ARG_TITLE=${android.net.Uri.encode(title)}")
|
if (!title.isNullOrBlank()) add("$ARG_TITLE=${android.net.Uri.encode(title)}")
|
||||||
if (!tag.isNullOrBlank()) add("$ARG_TAG=${android.net.Uri.encode(tag)}")
|
if (!tag.isNullOrBlank()) add("$ARG_TAG=${android.net.Uri.encode(tag)}")
|
||||||
if (filterId != null) add("$ARG_FILTER_ID=$filterId")
|
if (filterId != null) add("$ARG_FILTER_ID=$filterId")
|
||||||
|
if (dueToday) add("$ARG_DUE_TODAY=true")
|
||||||
}
|
}
|
||||||
return if (params.isEmpty()) "taskEdit" else "taskEdit?${params.joinToString("&")}"
|
return if (params.isEmpty()) "taskEdit" else "taskEdit?${params.joinToString("&")}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,15 @@ import de.jeanlucmakiola.agendula.domain.SmartList
|
|||||||
* [AgendulaNavHost], which pushes it over whatever is showing.
|
* [AgendulaNavHost], which pushes it over whatever is showing.
|
||||||
*/
|
*/
|
||||||
sealed interface NavRequest {
|
sealed interface NavRequest {
|
||||||
/** One task's detail; [occurrenceStart] (epoch millis) picks the occurrence of a series. */
|
/**
|
||||||
data class OpenTask(val taskId: Long, val occurrenceStart: Long? = null) : NavRequest
|
* One task's detail; [occurrenceStart] (epoch millis) picks the occurrence of a series.
|
||||||
|
* [returnHome] leaves the app once the detail is closed (a widget row, #93).
|
||||||
|
*/
|
||||||
|
data class OpenTask(
|
||||||
|
val taskId: Long,
|
||||||
|
val occurrenceStart: Long? = null,
|
||||||
|
val returnHome: Boolean = false,
|
||||||
|
) : NavRequest
|
||||||
|
|
||||||
/** Settings → Accounts, where a sync report's detail lives. */
|
/** Settings → Accounts, where a sync report's detail lives. */
|
||||||
data object OpenAccounts : NavRequest
|
data object OpenAccounts : NavRequest
|
||||||
@@ -20,9 +27,16 @@ sealed interface NavRequest {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A new task, optionally titled with text shared from another app, or
|
* A new task, optionally titled with text shared from another app, or
|
||||||
* preset to a list — a widget's own add button, when it shows one list.
|
* preset to a list — a widget's own add button, when it shows one list —
|
||||||
|
* or due today, from a Today widget.
|
||||||
|
* [returnHome] leaves the app once the form is closed (#93).
|
||||||
*/
|
*/
|
||||||
data class NewTask(val title: String? = null, val listId: Long? = null) : NavRequest
|
data class NewTask(
|
||||||
|
val title: String? = null,
|
||||||
|
val listId: Long? = null,
|
||||||
|
val dueToday: Boolean = false,
|
||||||
|
val returnHome: Boolean = false,
|
||||||
|
) : NavRequest
|
||||||
|
|
||||||
/** One of the smart lists — the launcher's "Today" shortcut, a widget header. */
|
/** One of the smart lists — the launcher's "Today" shortcut, a widget header. */
|
||||||
data class OpenSmart(val list: SmartList) : NavRequest
|
data class OpenSmart(val list: SmartList) : NavRequest
|
||||||
|
|||||||
@@ -12,16 +12,23 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.colorResource
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.agendula.R
|
import de.jeanlucmakiola.agendula.R
|
||||||
|
import de.jeanlucmakiola.agendula.data.appname.LauncherName
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.TimeFormatPref
|
import de.jeanlucmakiola.agendula.data.prefs.TimeFormatPref
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.is24Hour
|
import de.jeanlucmakiola.agendula.data.prefs.is24Hour
|
||||||
import de.jeanlucmakiola.agendula.ui.common.formatMinuteOfDay
|
import de.jeanlucmakiola.agendula.ui.common.formatMinuteOfDay
|
||||||
import de.jeanlucmakiola.agendula.ui.common.localeFirstDayOfWeek
|
import de.jeanlucmakiola.agendula.ui.common.localeFirstDayOfWeek
|
||||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.LauncherMark
|
||||||
|
import de.jeanlucmakiola.floret.components.LauncherNameChooser
|
||||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
@@ -38,6 +45,8 @@ internal fun AppearanceScreen(
|
|||||||
) {
|
) {
|
||||||
val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||||
var showTheme by remember { mutableStateOf(false) }
|
var showTheme by remember { mutableStateOf(false) }
|
||||||
|
var showAppName by remember { mutableStateOf(false) }
|
||||||
|
val launcherName by viewModel.launcherName.collectAsStateWithLifecycle()
|
||||||
var showTimeFormat by remember { mutableStateOf(false) }
|
var showTimeFormat by remember { mutableStateOf(false) }
|
||||||
var showWeekStart by remember { mutableStateOf(false) }
|
var showWeekStart by remember { mutableStateOf(false) }
|
||||||
val systemIs24Hour = DateFormat.is24HourFormat(LocalContext.current)
|
val systemIs24Hour = DateFormat.is24HourFormat(LocalContext.current)
|
||||||
@@ -90,6 +99,36 @@ internal fun AppearanceScreen(
|
|||||||
position = Position.Bottom,
|
position = Position.Bottom,
|
||||||
onClick = { showWeekStart = true },
|
onClick = { showWeekStart = true },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_app_name),
|
||||||
|
summary = launcherNameLabel(launcherName),
|
||||||
|
position = Position.Alone,
|
||||||
|
onClick = { showAppName = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showAppName) {
|
||||||
|
FullScreenPicker(
|
||||||
|
title = stringResource(R.string.settings_app_name),
|
||||||
|
onDismiss = { showAppName = false },
|
||||||
|
predictiveBack = true,
|
||||||
|
) {
|
||||||
|
LauncherNameChooser(
|
||||||
|
summary = stringResource(R.string.settings_app_name_hint),
|
||||||
|
options = LauncherName.entries,
|
||||||
|
selected = launcherName,
|
||||||
|
label = { launcherNameLabel(it) },
|
||||||
|
onSelect = viewModel::setLauncherName,
|
||||||
|
mark = {
|
||||||
|
LauncherMark(
|
||||||
|
background = colorResource(R.color.ic_launcher_background),
|
||||||
|
foreground = painterResource(R.drawable.ic_launcher_foreground),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showTimeFormat) {
|
if (showTimeFormat) {
|
||||||
@@ -140,6 +179,7 @@ internal fun AppearanceScreen(
|
|||||||
options = ThemeMode.entries,
|
options = ThemeMode.entries,
|
||||||
selected = state.settings.themeMode,
|
selected = state.settings.themeMode,
|
||||||
label = { stringResource(themeLabel(it)) },
|
label = { stringResource(themeLabel(it)) },
|
||||||
|
summary = { if (it == ThemeMode.BLACK) stringResource(R.string.settings_theme_black_summary) else null },
|
||||||
onSelect = viewModel::setThemeMode,
|
onSelect = viewModel::setThemeMode,
|
||||||
onDismiss = { showTheme = false },
|
onDismiss = { showTheme = false },
|
||||||
)
|
)
|
||||||
@@ -163,8 +203,17 @@ private fun weekStartLabel(day: DayOfWeek?): String =
|
|||||||
/** 14:00, the sample time "Automatic" is shown with. */
|
/** 14:00, the sample time "Automatic" is shown with. */
|
||||||
private const val SAMPLE_MINUTE_OF_DAY = 14 * 60
|
private const val SAMPLE_MINUTE_OF_DAY = 14 * 60
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun launcherNameLabel(name: LauncherName): String = stringResource(
|
||||||
|
when (name) {
|
||||||
|
LauncherName.AGENDULA -> R.string.app_name
|
||||||
|
LauncherName.TASKS -> R.string.app_name_tasks_alias
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
private fun themeLabel(mode: ThemeMode): Int = when (mode) {
|
private fun themeLabel(mode: ThemeMode): Int = when (mode) {
|
||||||
ThemeMode.SYSTEM -> R.string.settings_theme_system
|
ThemeMode.SYSTEM -> R.string.settings_theme_system
|
||||||
ThemeMode.LIGHT -> R.string.settings_theme_light
|
ThemeMode.LIGHT -> R.string.settings_theme_light
|
||||||
ThemeMode.DARK -> R.string.settings_theme_dark
|
ThemeMode.DARK -> R.string.settings_theme_dark
|
||||||
|
ThemeMode.BLACK -> R.string.settings_theme_black
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,8 @@ package de.jeanlucmakiola.agendula.ui.settings
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.jeanlucmakiola.agendula.data.appname.LauncherName
|
||||||
|
import de.jeanlucmakiola.agendula.data.appname.LauncherNameManager
|
||||||
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.Settings
|
import de.jeanlucmakiola.agendula.data.prefs.Settings
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||||
@@ -33,6 +35,7 @@ import kotlinx.coroutines.flow.flowOn
|
|||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import java.time.DayOfWeek
|
import java.time.DayOfWeek
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -102,7 +105,8 @@ class SettingsViewModel @Inject constructor(
|
|||||||
private val oneShotImport: OneShotImport,
|
private val oneShotImport: OneShotImport,
|
||||||
private val reminderScheduler: ReminderScheduler,
|
private val reminderScheduler: ReminderScheduler,
|
||||||
private val taskNotifier: TaskNotifier,
|
private val taskNotifier: TaskNotifier,
|
||||||
@IoDispatcher io: CoroutineDispatcher,
|
private val launcherNameManager: LauncherNameManager,
|
||||||
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
repository: TasksRepository,
|
repository: TasksRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -184,7 +188,6 @@ class SettingsViewModel @Inject constructor(
|
|||||||
transferState.update { it.copy(confirming = false, running = true, outcome = null) }
|
transferState.update { it.copy(confirming = false, running = true, outcome = null) }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val result = externalImport.run()
|
val result = externalImport.run()
|
||||||
if (result is TransferResult.Copied) runCatching { reminderScheduler.sync() }
|
|
||||||
transferState.update {
|
transferState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
running = false,
|
running = false,
|
||||||
@@ -208,7 +211,6 @@ class SettingsViewModel @Inject constructor(
|
|||||||
retryingLegacyImport.value = true
|
retryingLegacyImport.value = true
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { oneShotImport.reimportFromArchive() }
|
runCatching { oneShotImport.reimportFromArchive() }
|
||||||
runCatching { reminderScheduler.sync() }
|
|
||||||
retryingLegacyImport.value = false
|
retryingLegacyImport.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,6 +219,23 @@ class SettingsViewModel @Inject constructor(
|
|||||||
|
|
||||||
val legacyImportRetrying: StateFlow<Boolean> = retryingLegacyImport
|
val legacyImportRetrying: StateFlow<Boolean> = retryingLegacyImport
|
||||||
|
|
||||||
|
private val _launcherName = MutableStateFlow(LauncherName.AGENDULA)
|
||||||
|
val launcherName: StateFlow<LauncherName> = _launcherName
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch { _launcherName.value = withContext(io) { launcherNameManager.current() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setLauncherName(name: LauncherName) {
|
||||||
|
_launcherName.value = name
|
||||||
|
viewModelScope.launch {
|
||||||
|
_launcherName.value = withContext(io) {
|
||||||
|
launcherNameManager.set(name)
|
||||||
|
launcherNameManager.current()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun setThemeMode(mode: ThemeMode) = viewModelScope.launch { prefs.setThemeMode(mode) }
|
fun setThemeMode(mode: ThemeMode) = viewModelScope.launch { prefs.setThemeMode(mode) }
|
||||||
fun setDynamicColor(enabled: Boolean) = viewModelScope.launch { prefs.setDynamicColor(enabled) }
|
fun setDynamicColor(enabled: Boolean) = viewModelScope.launch { prefs.setDynamicColor(enabled) }
|
||||||
fun setDefaultList(id: Long?) = viewModelScope.launch { prefs.setDefaultListId(id) }
|
fun setDefaultList(id: Long?) = viewModelScope.launch { prefs.setDefaultListId(id) }
|
||||||
|
|||||||
@@ -112,6 +112,8 @@ import de.jeanlucmakiola.agendula.domain.TaskFilter
|
|||||||
import de.jeanlucmakiola.agendula.domain.TaskSection
|
import de.jeanlucmakiola.agendula.domain.TaskSection
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskSections
|
import de.jeanlucmakiola.agendula.domain.TaskSections
|
||||||
import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat
|
||||||
|
import de.jeanlucmakiola.agendula.ui.common.SyncPullBox
|
||||||
|
import de.jeanlucmakiola.agendula.ui.common.ignoringEdgeSwipes
|
||||||
import de.jeanlucmakiola.agendula.ui.common.priorityAccent
|
import de.jeanlucmakiola.agendula.ui.common.priorityAccent
|
||||||
import de.jeanlucmakiola.agendula.ui.lists.ListEditorSheet
|
import de.jeanlucmakiola.agendula.ui.lists.ListEditorSheet
|
||||||
import de.jeanlucmakiola.agendula.ui.lists.listWriteFailureMessage
|
import de.jeanlucmakiola.agendula.ui.lists.listWriteFailureMessage
|
||||||
@@ -144,6 +146,8 @@ fun TaskListScreen(
|
|||||||
viewModel: TaskListViewModel = hiltViewModel(),
|
viewModel: TaskListViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val syncAvailable by viewModel.syncAvailable.collectAsStateWithLifecycle()
|
||||||
|
val syncing by viewModel.syncing.collectAsStateWithLifecycle()
|
||||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
||||||
val content = state as? TaskListUiState.Content
|
val content = state as? TaskListUiState.Content
|
||||||
val list = content?.list
|
val list = content?.list
|
||||||
@@ -247,13 +251,21 @@ fun TaskListScreen(
|
|||||||
},
|
},
|
||||||
) { inner ->
|
) { inner ->
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
when (val s = state) {
|
SyncPullBox(
|
||||||
TaskListUiState.Loading -> Unit // brief; avoids a flash before first emission
|
available = syncAvailable,
|
||||||
TaskListUiState.Failure -> CenteredMessage(stringResource(R.string.task_list_failure), inner)
|
syncing = syncing,
|
||||||
is TaskListUiState.Content -> TaskListBody(
|
onSync = viewModel::syncAll,
|
||||||
s, filter, inner, onOpenTask, onRequestDelete, viewModel,
|
modifier = Modifier.fillMaxSize(),
|
||||||
onClearCompleted = if (listId != null && list?.isReadOnly != true) ({ confirmClear = true }) else null,
|
topPadding = inner.calculateTopPadding(),
|
||||||
)
|
) {
|
||||||
|
when (val s = state) {
|
||||||
|
TaskListUiState.Loading -> Unit // brief; avoids a flash before first emission
|
||||||
|
TaskListUiState.Failure -> CenteredMessage(stringResource(R.string.task_list_failure), inner)
|
||||||
|
is TaskListUiState.Content -> TaskListBody(
|
||||||
|
s, filter, inner, onOpenTask, onRequestDelete, viewModel,
|
||||||
|
onClearCompleted = if (listId != null && list?.isReadOnly != true) ({ confirmClear = true }) else null,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// A FAB-height band anchored at the bottom-start with the FAB's own
|
// A FAB-height band anchored at the bottom-start with the FAB's own
|
||||||
// margin; centring the chip in it lines the chip up beside the bottom-end
|
// margin; centring the chip in it lines the chip up beside the bottom-end
|
||||||
@@ -355,7 +367,7 @@ private fun TaskListBody(
|
|||||||
viewModel: TaskListViewModel,
|
viewModel: TaskListViewModel,
|
||||||
onClearCompleted: (() -> Unit)? = null,
|
onClearCompleted: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val (todayStart, todayEnd) = remember { DayWindow.today(Clock.System.now(), ZoneId.systemDefault()) }
|
val (todayStart, todayEnd) = remember(state.tasks) { DayWindow.today(Clock.System.now(), ZoneId.systemDefault()) }
|
||||||
// A parent represents its children via a progress chip (counts come from the
|
// A parent represents its children via a progress chip (counts come from the
|
||||||
// repository, over the full set), so drop children from the flat list — unless
|
// repository, over the full set), so drop children from the flat list — unless
|
||||||
// their parent isn't in this view (can happen in smart lists), where we keep
|
// their parent isn't in this view (can happen in smart lists), where we keep
|
||||||
@@ -364,15 +376,15 @@ private fun TaskListBody(
|
|||||||
val present = state.tasks.mapTo(HashSet()) { it.taskId }
|
val present = state.tasks.mapTo(HashSet()) { it.taskId }
|
||||||
state.tasks.filter { (it.parentId ?: 0L) <= 0L || it.parentId !in present }
|
state.tasks.filter { (it.parentId ?: 0L) <= 0L || it.parentId !in present }
|
||||||
}
|
}
|
||||||
val sections = remember(displayTasks) { TaskSections.of(displayTasks, todayStart, todayEnd) }
|
val sections = remember(displayTasks, todayStart) {
|
||||||
|
TaskSections.of(displayTasks, todayStart, todayEnd, todayList = filter == TaskFilter.Smart(SmartList.TODAY))
|
||||||
|
}
|
||||||
val showHeaders = sections.size > 1
|
val showHeaders = sections.size > 1
|
||||||
val listId = (filter as? TaskFilter.OfList)?.listId
|
val listId = (filter as? TaskFilter.OfList)?.listId
|
||||||
// In a mixed view (a smart list) the list colour bar + name disambiguate
|
// In a mixed view (a smart list) the list colour bar + name disambiguate
|
||||||
// rows; inside a single real list both are redundant.
|
// rows; inside a single real list both are redundant.
|
||||||
val showListName = filter is TaskFilter.Smart || filter is TaskFilter.Tagged ||
|
val showListName = filter is TaskFilter.Smart || filter is TaskFilter.Tagged ||
|
||||||
(filter is TaskFilter.Saved && state.savedFilter?.listIds?.size != 1)
|
(filter is TaskFilter.Saved && state.savedFilter?.listIds?.size != 1)
|
||||||
// Completed tasks collapse away by default to keep the active list in focus.
|
|
||||||
var completedExpanded by rememberSaveable { mutableStateOf(false) }
|
|
||||||
// Parents the user expanded to reveal their subtasks inline. Children are
|
// Parents the user expanded to reveal their subtasks inline. Children are
|
||||||
// grouped from the same flat task set the list already holds.
|
// grouped from the same flat task set the list already holds.
|
||||||
var expandedParents by rememberSaveable { mutableStateOf(emptySet<Long>()) }
|
var expandedParents by rememberSaveable { mutableStateOf(emptySet<Long>()) }
|
||||||
@@ -420,23 +432,20 @@ private fun TaskListBody(
|
|||||||
}
|
}
|
||||||
|
|
||||||
sections.forEach { section ->
|
sections.forEach { section ->
|
||||||
val collapsible = showHeaders && section.section == TaskSection.COMPLETED
|
// Every header folds its section away (#91); Done starts folded.
|
||||||
|
val expanded = !showHeaders || section.section !in state.collapsedSections
|
||||||
if (showHeaders) {
|
if (showHeaders) {
|
||||||
stickyHeader(key = "hdr-${section.section}") {
|
stickyHeader(key = "hdr-${section.section}") {
|
||||||
if (collapsible) {
|
CollapsibleSectionHeader(
|
||||||
CollapsibleSectionHeader(
|
label = sectionLabel(section.section),
|
||||||
label = sectionLabel(section.section),
|
count = section.tasks.size,
|
||||||
count = section.tasks.size,
|
expanded = expanded,
|
||||||
expanded = completedExpanded,
|
onToggle = { viewModel.setSectionCollapsed(section.section, expanded) },
|
||||||
onToggle = { completedExpanded = !completedExpanded },
|
onClear = onClearCompleted.takeIf { section.section == TaskSection.COMPLETED },
|
||||||
onClear = onClearCompleted,
|
)
|
||||||
)
|
|
||||||
} else {
|
|
||||||
SectionHeader(sectionLabel(section.section))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!collapsible || completedExpanded) {
|
if (expanded) {
|
||||||
// Flatten the section into one grouped run: each parent, followed
|
// Flatten the section into one grouped run: each parent, followed
|
||||||
// by its subtasks when expanded. Each row's corners then come from
|
// by its subtasks when expanded. Each row's corners then come from
|
||||||
// its place in the whole run (positionOf below) — so the children
|
// its place in the whole run (positionOf below) — so the children
|
||||||
@@ -655,7 +664,7 @@ private fun TaskRow(
|
|||||||
|
|
||||||
SwipeToDismissBox(
|
SwipeToDismissBox(
|
||||||
state = dismissState,
|
state = dismissState,
|
||||||
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap),
|
modifier = modifier.ignoringEdgeSwipes().fillMaxWidth().padding(horizontal = 16.dp).then(gap),
|
||||||
// dismissDirection (not targetValue) tracks the live drag, so the colour +
|
// dismissDirection (not targetValue) tracks the live drag, so the colour +
|
||||||
// icon reveal as you swipe rather than only once the threshold is crossed.
|
// icon reveal as you swipe rather than only once the threshold is crossed.
|
||||||
backgroundContent = { SwipeBackground(dismissState.dismissDirection, task.isCompleted) },
|
backgroundContent = { SwipeBackground(dismissState.dismissDirection, task.isCompleted) },
|
||||||
@@ -1037,7 +1046,7 @@ private fun SubtaskRow(
|
|||||||
val gap = if (lastInRun) Modifier else Modifier.padding(bottom = 2.dp)
|
val gap = if (lastInRun) Modifier else Modifier.padding(bottom = 2.dp)
|
||||||
SwipeToDismissBox(
|
SwipeToDismissBox(
|
||||||
state = dismissState,
|
state = dismissState,
|
||||||
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap),
|
modifier = modifier.ignoringEdgeSwipes().fillMaxWidth().padding(horizontal = 16.dp).then(gap),
|
||||||
backgroundContent = { SwipeBackground(dismissState.dismissDirection, task.isCompleted) },
|
backgroundContent = { SwipeBackground(dismissState.dismissDirection, task.isCompleted) },
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
@@ -1127,19 +1136,6 @@ private fun cardShape(position: Position, full: androidx.compose.ui.unit.Dp, sma
|
|||||||
Position.Bottom -> RoundedCornerShape(topStart = small, topEnd = small, bottomStart = full, bottomEnd = full)
|
Position.Bottom -> RoundedCornerShape(topStart = small, topEnd = small, bottomStart = full, bottomEnd = full)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SectionHeader(text: String) {
|
|
||||||
// Opaque surface so rows scroll cleanly under the pinned header.
|
|
||||||
Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A tappable section header with a count and a chevron that rotates on expand. */
|
/** A tappable section header with a count and a chevron that rotates on expand. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun CollapsibleSectionHeader(
|
private fun CollapsibleSectionHeader(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.agendula.ui.tasklist
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.jeanlucmakiola.agendula.data.prefs.DEFAULT_COLLAPSED_SECTIONS
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.agendula.data.sync.RemoteLists
|
import de.jeanlucmakiola.agendula.data.sync.RemoteLists
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||||
@@ -14,11 +15,13 @@ import de.jeanlucmakiola.agendula.domain.TaskFiltering
|
|||||||
import de.jeanlucmakiola.agendula.domain.tagsInUse
|
import de.jeanlucmakiola.agendula.domain.tagsInUse
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskSection
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskSortOrder
|
import de.jeanlucmakiola.agendula.domain.TaskSortOrder
|
||||||
import de.jeanlucmakiola.agendula.domain.comparator
|
import de.jeanlucmakiola.agendula.domain.comparator
|
||||||
import de.jeanlucmakiola.agendula.ui.lists.ListWriteFailure
|
import de.jeanlucmakiola.agendula.ui.lists.ListWriteFailure
|
||||||
import de.jeanlucmakiola.agendula.ui.lists.asFailure
|
import de.jeanlucmakiola.agendula.ui.lists.asFailure
|
||||||
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.ManualSync
|
||||||
import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope
|
import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
@@ -56,6 +59,8 @@ sealed interface TaskListUiState {
|
|||||||
/** Whether a real list uses the bottom quick-add bar instead of the FAB. */
|
/** Whether a real list uses the bottom quick-add bar instead of the FAB. */
|
||||||
val bottomAddBar: Boolean = false,
|
val bottomAddBar: Boolean = false,
|
||||||
val sortOrder: TaskSortOrder = TaskSortOrder.DUE,
|
val sortOrder: TaskSortOrder = TaskSortOrder.DUE,
|
||||||
|
/** Sections whose rows are folded under their header. */
|
||||||
|
val collapsedSections: Set<TaskSection> = DEFAULT_COLLAPSED_SECTIONS,
|
||||||
/** This real list is kept out of the smart lists. */
|
/** This real list is kept out of the smart lists. */
|
||||||
val hiddenFromSmartLists: Boolean = false,
|
val hiddenFromSmartLists: Boolean = false,
|
||||||
/** Tags the unnarrowed view carries, offered as filter chips. */
|
/** Tags the unnarrowed view carries, offered as filter chips. */
|
||||||
@@ -89,9 +94,17 @@ class TaskListViewModel @Inject constructor(
|
|||||||
private val repository: TasksRepository,
|
private val repository: TasksRepository,
|
||||||
private val settingsPrefs: SettingsPrefs,
|
private val settingsPrefs: SettingsPrefs,
|
||||||
private val remoteLists: RemoteLists,
|
private val remoteLists: RemoteLists,
|
||||||
|
private val manualSync: ManualSync,
|
||||||
@ApplicationScope private val appScope: CoroutineScope,
|
@ApplicationScope private val appScope: CoroutineScope,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
/** Pull to sync (#73); off without an account. */
|
||||||
|
val syncAvailable: StateFlow<Boolean> =
|
||||||
|
manualSync.available.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||||
|
val syncing: StateFlow<Boolean> = manualSync.syncing
|
||||||
|
|
||||||
|
fun syncAll() = manualSync.syncAll()
|
||||||
|
|
||||||
private val filter = MutableStateFlow<TaskFilter?>(null)
|
private val filter = MutableStateFlow<TaskFilter?>(null)
|
||||||
|
|
||||||
private val tagFilter = MutableStateFlow<Set<String>>(emptySet())
|
private val tagFilter = MutableStateFlow<Set<String>>(emptySet())
|
||||||
@@ -144,6 +157,7 @@ class TaskListViewModel @Inject constructor(
|
|||||||
showAddSubtaskRow = settings.showAddSubtaskRow,
|
showAddSubtaskRow = settings.showAddSubtaskRow,
|
||||||
bottomAddBar = settings.bottomAddBar,
|
bottomAddBar = settings.bottomAddBar,
|
||||||
sortOrder = settings.sortOrder,
|
sortOrder = settings.sortOrder,
|
||||||
|
collapsedSections = settings.collapsedSections,
|
||||||
hiddenFromSmartLists = st.list?.id?.let { it in settings.hiddenFromSmartLists } ?: false,
|
hiddenFromSmartLists = st.list?.id?.let { it in settings.hiddenFromSmartLists } ?: false,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -277,6 +291,9 @@ class TaskListViewModel @Inject constructor(
|
|||||||
fun setHiddenFromSmartLists(listId: Long, hidden: Boolean) =
|
fun setHiddenFromSmartLists(listId: Long, hidden: Boolean) =
|
||||||
viewModelScope.launch { settingsPrefs.setHiddenFromSmartLists(listId, hidden) }
|
viewModelScope.launch { settingsPrefs.setHiddenFromSmartLists(listId, hidden) }
|
||||||
|
|
||||||
|
fun setSectionCollapsed(section: TaskSection, collapsed: Boolean) =
|
||||||
|
viewModelScope.launch { settingsPrefs.setSectionCollapsed(section, collapsed) }
|
||||||
|
|
||||||
fun setSortOrder(order: TaskSortOrder) = viewModelScope.launch { settingsPrefs.setSortOrder(order) }
|
fun setSortOrder(order: TaskSortOrder) = viewModelScope.launch { settingsPrefs.setSortOrder(order) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.ui.theme
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.app.UiModeManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.annotation.StyleRes
|
||||||
|
import de.jeanlucmakiola.agendula.R
|
||||||
|
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hands the app's theme setting to the system, which draws the launch window
|
||||||
|
* and splash before any of the app runs (#119). The system keeps both across
|
||||||
|
* launches, so the next start already matches.
|
||||||
|
*/
|
||||||
|
object SystemTheme {
|
||||||
|
|
||||||
|
/** The light or dark the app's resources follow, as [UiModeManager.setApplicationNightMode] takes it. */
|
||||||
|
fun nightModeOf(mode: ThemeMode): Int = when (mode) {
|
||||||
|
ThemeMode.SYSTEM -> UiModeManager.MODE_NIGHT_AUTO
|
||||||
|
ThemeMode.LIGHT -> UiModeManager.MODE_NIGHT_NO
|
||||||
|
ThemeMode.DARK, ThemeMode.BLACK -> UiModeManager.MODE_NIGHT_YES
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The splash theme whose background is the one the app opens on. */
|
||||||
|
@StyleRes
|
||||||
|
fun splashThemeOf(mode: ThemeMode, dynamicColor: Boolean): Int = when {
|
||||||
|
mode.isPureBlack -> R.style.Theme_Agendula_Black
|
||||||
|
dynamicColor -> R.style.Theme_Agendula
|
||||||
|
else -> R.style.Theme_Agendula_Fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies [mode] and [dynamicColor]. A night mode the activity does not
|
||||||
|
* have yet recreates it, once; the same one again changes nothing.
|
||||||
|
*/
|
||||||
|
fun apply(activity: Activity, mode: ThemeMode, dynamicColor: Boolean) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
activity.getSystemService(UiModeManager::class.java)?.setApplicationNightMode(nightModeOf(mode))
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
activity.splashScreen.setSplashScreenTheme(splashThemeOf(mode, dynamicColor))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import de.jeanlucmakiola.floret.identity.FloretExpressiveTheme
|
|||||||
fun AgendulaTheme(
|
fun AgendulaTheme(
|
||||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
dynamicColor: Boolean = true,
|
dynamicColor: Boolean = true,
|
||||||
|
pureBlack: Boolean = false,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
FloretExpressiveTheme(
|
FloretExpressiveTheme(
|
||||||
@@ -22,6 +23,7 @@ fun AgendulaTheme(
|
|||||||
darkScheme = AgendulaDarkFallback,
|
darkScheme = AgendulaDarkFallback,
|
||||||
darkTheme = darkTheme,
|
darkTheme = darkTheme,
|
||||||
dynamicColor = dynamicColor,
|
dynamicColor = dynamicColor,
|
||||||
|
pureBlack = pureBlack,
|
||||||
typography = AgendulaTypography,
|
typography = AgendulaTypography,
|
||||||
content = content,
|
content = content,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -194,10 +194,12 @@ private fun TaskWidgetBody(
|
|||||||
Spacer(GlanceModifier.height(R.dimen.widget_tasks_header_gap_tight))
|
Spacer(GlanceModifier.height(R.dimen.widget_tasks_header_gap_tight))
|
||||||
WidgetDoneState(context.getString(R.string.widget_empty), context.getString(R.string.widget_empty_hint))
|
WidgetDoneState(context.getString(R.string.widget_empty), context.getString(R.string.widget_empty_hint))
|
||||||
} else {
|
} else {
|
||||||
|
val todayList = filter == TaskFilter.Smart(SmartList.TODAY)
|
||||||
val allSections = widgetSections(
|
val allSections = widgetSections(
|
||||||
data.rows,
|
data.rows,
|
||||||
data.groupByList,
|
data.groupByList,
|
||||||
data.listNames,
|
data.listNames,
|
||||||
|
todayList,
|
||||||
) { kind -> context.getString(dueBucketLabel(kind)) }
|
) { kind -> context.getString(dueBucketLabel(kind)) }
|
||||||
// A lone group whose bucket repeats the widget's own smart list
|
// A lone group whose bucket repeats the widget's own smart list
|
||||||
// (the "Today" smart list showing one "Today" bucket) earns no
|
// (the "Today" smart list showing one "Today" bucket) earns no
|
||||||
@@ -248,7 +250,8 @@ private fun TaskWidgetBody(
|
|||||||
// (Today / Tomorrow) whenever one is shown — and when
|
// (Today / Tomorrow) whenever one is shown — and when
|
||||||
// it isn't, that's only because it would have repeated
|
// it isn't, that's only because it would have repeated
|
||||||
// the title, which names the same day just as well.
|
// the title, which names the same day just as well.
|
||||||
suppressDayLabel = !data.groupByList,
|
suppressDayLabel = !data.groupByList &&
|
||||||
|
(!todayList || section.row.dueKind == DueKind.TODAY),
|
||||||
)
|
)
|
||||||
Spacer(GlanceModifier.height(8.dp))
|
Spacer(GlanceModifier.height(8.dp))
|
||||||
}
|
}
|
||||||
@@ -304,7 +307,9 @@ private fun TaskWidgetHeader(title: String, filter: TaskFilter, singleListId: Lo
|
|||||||
WidgetHeaderAction(
|
WidgetHeaderAction(
|
||||||
icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add),
|
icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add),
|
||||||
contentDescription = context.getString(R.string.new_task),
|
contentDescription = context.getString(R.string.new_task),
|
||||||
action = actionStartActivity(MainActivity.newTaskIntent(context, singleListId)),
|
action = actionStartActivity(
|
||||||
|
MainActivity.newTaskIntent(context, singleListId, dueToday = filter == TaskFilter.Smart(SmartList.TODAY)),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -328,7 +333,7 @@ internal fun AgendaTaskRow(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val title = row.title.ifBlank { context.getString(R.string.task_untitled) }
|
val title = row.title.ifBlank { context.getString(R.string.task_untitled) }
|
||||||
val open = actionStartActivity(
|
val open = actionStartActivity(
|
||||||
MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds()),
|
MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds(), returnHome = true),
|
||||||
)
|
)
|
||||||
val onColor = if (colored) GlanceColorProvider(Color.White) else GlanceTheme.colors.onSurface
|
val onColor = if (colored) GlanceColorProvider(Color.White) else GlanceTheme.colors.onSurface
|
||||||
val onColorVariant = if (colored) GlanceColorProvider(Color.White.copy(alpha = 0.8f)) else GlanceTheme.colors.onSurfaceVariant
|
val onColorVariant = if (colored) GlanceColorProvider(Color.White.copy(alpha = 0.8f)) else GlanceTheme.colors.onSurfaceVariant
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ import dagger.hilt.android.AndroidEntryPoint
|
|||||||
import de.jeanlucmakiola.agendula.R
|
import de.jeanlucmakiola.agendula.R
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.Settings
|
import de.jeanlucmakiola.agendula.data.prefs.Settings
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||||
import de.jeanlucmakiola.agendula.domain.SavedFilter
|
import de.jeanlucmakiola.agendula.domain.SavedFilter
|
||||||
import de.jeanlucmakiola.agendula.domain.SmartList
|
import de.jeanlucmakiola.agendula.domain.SmartList
|
||||||
@@ -135,12 +134,12 @@ class TaskWidgetConfigActivity : ComponentActivity() {
|
|||||||
rowColor = parseWidgetRowColor(state[WIDGET_ROW_COLOR_KEY])
|
rowColor = parseWidgetRowColor(state[WIDGET_ROW_COLOR_KEY])
|
||||||
selectedListIds = parseWidgetListIds(state[WIDGET_LIST_IDS_KEY])
|
selectedListIds = parseWidgetListIds(state[WIDGET_LIST_IDS_KEY])
|
||||||
}
|
}
|
||||||
val dark = when (settings.themeMode) {
|
val dark = settings.themeMode.isDark(isSystemInDarkTheme())
|
||||||
ThemeMode.SYSTEM -> isSystemInDarkTheme()
|
AgendulaTheme(
|
||||||
ThemeMode.LIGHT -> false
|
darkTheme = dark,
|
||||||
ThemeMode.DARK -> true
|
dynamicColor = settings.dynamicColor,
|
||||||
}
|
pureBlack = settings.themeMode.isPureBlack,
|
||||||
AgendulaTheme(darkTheme = dark, dynamicColor = settings.dynamicColor) {
|
) {
|
||||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {}
|
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {}
|
||||||
WidgetFilterPicker(
|
WidgetFilterPicker(
|
||||||
kind = kind,
|
kind = kind,
|
||||||
|
|||||||
@@ -3,14 +3,11 @@ package de.jeanlucmakiola.agendula.widget
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.StoreChangeDebouncer
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||||
import de.jeanlucmakiola.floret.glance.WidgetRollover
|
import de.jeanlucmakiola.floret.glance.WidgetRollover
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.FlowPreview
|
|
||||||
import kotlinx.coroutines.channels.Channel
|
|
||||||
import kotlinx.coroutines.flow.debounce
|
|
||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -26,16 +23,10 @@ class TaskWidgetUpdater @Inject constructor(
|
|||||||
private val dataSource: TasksDataSource,
|
private val dataSource: TasksDataSource,
|
||||||
@ApplicationScope private val scope: CoroutineScope,
|
@ApplicationScope private val scope: CoroutineScope,
|
||||||
) {
|
) {
|
||||||
private val requests = Channel<Unit>(Channel.CONFLATED)
|
private val changes = StoreChangeDebouncer(scope, DEBOUNCE_MILLIS, dataSource::registerObserver, ::redraw)
|
||||||
private var observer: AutoCloseable? = null
|
|
||||||
|
|
||||||
@OptIn(FlowPreview::class)
|
|
||||||
fun start() {
|
fun start() {
|
||||||
if (observer != null) return
|
if (!changes.start()) return
|
||||||
observer = runCatching { dataSource.registerObserver { requests.trySend(Unit) } }.getOrNull()
|
|
||||||
scope.launch(Dispatchers.IO) {
|
|
||||||
requests.receiveAsFlow().debounce(DEBOUNCE_MILLIS).collect { redraw() }
|
|
||||||
}
|
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
runCatching { WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent) }
|
runCatching { WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent) }
|
||||||
}
|
}
|
||||||
@@ -44,9 +35,7 @@ class TaskWidgetUpdater @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Redraw soon; bursts collapse into one pass. */
|
/** Redraw soon; bursts collapse into one pass. */
|
||||||
fun requestRefresh() {
|
fun requestRefresh() = changes.request()
|
||||||
requests.trySend(Unit)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun redraw() {
|
private suspend fun redraw() {
|
||||||
runCatching {
|
runCatching {
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ private fun TodayWidgetBody(data: WidgetStatsData?, background: WidgetBackground
|
|||||||
WidgetHeaderAction(
|
WidgetHeaderAction(
|
||||||
icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add),
|
icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add),
|
||||||
contentDescription = context.getString(R.string.new_task),
|
contentDescription = context.getString(R.string.new_task),
|
||||||
action = actionStartActivity(MainActivity.newTaskIntent(context)),
|
action = actionStartActivity(MainActivity.newTaskIntent(context, dueToday = true)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(GlanceModifier.height(HeaderGap))
|
Spacer(GlanceModifier.height(HeaderGap))
|
||||||
|
|||||||
@@ -339,7 +339,7 @@ private fun ListDot(color: Int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun openTask(context: Context, row: WidgetTaskRow): Action =
|
private fun openTask(context: Context, row: WidgetTaskRow): Action =
|
||||||
actionStartActivity(MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds()))
|
actionStartActivity(MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds(), returnHome = true))
|
||||||
|
|
||||||
private fun completeTask(row: WidgetTaskRow): Action =
|
private fun completeTask(row: WidgetTaskRow): Action =
|
||||||
actionRunCallback<CompleteTaskAction>(
|
actionRunCallback<CompleteTaskAction>(
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ internal fun effectiveThemeMode(appThemeMode: ThemeMode, override: WidgetThemeMo
|
|||||||
override ?: when (appThemeMode) {
|
override ?: when (appThemeMode) {
|
||||||
ThemeMode.SYSTEM -> WidgetThemeMode.SYSTEM
|
ThemeMode.SYSTEM -> WidgetThemeMode.SYSTEM
|
||||||
ThemeMode.LIGHT -> WidgetThemeMode.LIGHT
|
ThemeMode.LIGHT -> WidgetThemeMode.LIGHT
|
||||||
ThemeMode.DARK -> WidgetThemeMode.DARK
|
ThemeMode.DARK, ThemeMode.BLACK -> WidgetThemeMode.DARK
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The smart lists a widget can show, in picker order. */
|
/** The smart lists a widget can show, in picker order. */
|
||||||
@@ -200,12 +200,14 @@ private fun headedRuns(runs: List<WidgetRun>): List<WidgetSection> =
|
|||||||
* (already sorted that way by [widgetRows]), otherwise one per [DueKind]
|
* (already sorted that way by [widgetRows]), otherwise one per [DueKind]
|
||||||
* bucket — "Overdue" / "Today" / "Tomorrow" / "Later" / "No date" — in that
|
* bucket — "Overdue" / "Today" / "Tomorrow" / "Later" / "No date" — in that
|
||||||
* fixed order, since [rows] isn't due-sorted. Each run becomes one header
|
* fixed order, since [rows] isn't due-sorted. Each run becomes one header
|
||||||
* followed by its own standalone, independently coloured task pills.
|
* followed by its own standalone, independently coloured task pills. The Today
|
||||||
|
* list ([todayList]) is one "Today" bucket, as a row may be there by its start.
|
||||||
*/
|
*/
|
||||||
internal fun widgetSections(
|
internal fun widgetSections(
|
||||||
rows: List<WidgetTaskRow>,
|
rows: List<WidgetTaskRow>,
|
||||||
groupByList: Boolean,
|
groupByList: Boolean,
|
||||||
listNames: Map<Long, String>,
|
listNames: Map<Long, String>,
|
||||||
|
todayList: Boolean = false,
|
||||||
dueBucketLabel: (DueKind?) -> String,
|
dueBucketLabel: (DueKind?) -> String,
|
||||||
): List<WidgetSection> {
|
): List<WidgetSection> {
|
||||||
if (groupByList) {
|
if (groupByList) {
|
||||||
@@ -220,7 +222,7 @@ internal fun widgetSections(
|
|||||||
}
|
}
|
||||||
return headedRuns(runs)
|
return headedRuns(runs)
|
||||||
}
|
}
|
||||||
val buckets = rows.groupBy { it.dueKind }
|
val buckets = rows.groupBy { if (todayList) DueKind.TODAY else it.dueKind }
|
||||||
val runs = DueKind.entries.plus(null).mapNotNull { kind ->
|
val runs = DueKind.entries.plus(null).mapNotNull { kind ->
|
||||||
buckets[kind]?.takeIf { it.isNotEmpty() }
|
buckets[kind]?.takeIf { it.isNotEmpty() }
|
||||||
?.let { WidgetRun("due:${kind?.name.orEmpty()}", dueBucketLabel(kind), it) }
|
?.let { WidgetRun("due:${kind?.name.orEmpty()}", dueBucketLabel(kind), it) }
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import de.jeanlucmakiola.agendula.data.prefs.is24Hour
|
|||||||
import de.jeanlucmakiola.agendula.domain.SmartList
|
import de.jeanlucmakiola.agendula.domain.SmartList
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskFiltering
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
import de.jeanlucmakiola.agendula.domain.calendarDate
|
import de.jeanlucmakiola.agendula.domain.calendarDate
|
||||||
import de.jeanlucmakiola.agendula.domain.comparator
|
import de.jeanlucmakiola.agendula.domain.comparator
|
||||||
|
import de.jeanlucmakiola.floret.time.DayWindow
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
@@ -45,10 +47,15 @@ internal sealed interface WidgetStatsData {
|
|||||||
|
|
||||||
private const val MAX_TODAY_ROWS = 20
|
private const val MAX_TODAY_ROWS = 20
|
||||||
|
|
||||||
|
/** Whether this open task is on the Today widget's plate: in the app's Today, or overdue. */
|
||||||
|
private fun Task.isDueToday(now: Instant, today: LocalDate, todayStart: Instant, todayEnd: Instant, zone: ZoneId): Boolean =
|
||||||
|
TaskFiltering.isToday(this, todayStart, todayEnd, zone) ||
|
||||||
|
due?.let { isOverdue(it, isAllDay, now, today, zone) } == true
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How far through today a set of [open] and [completed] tasks says you are:
|
* How far through today a set of [open] and [completed] tasks says you are:
|
||||||
* tasks done today (by [Task.completedAt]'s local date), tasks still open that
|
* the app's Today tasks done and still open ([TaskFiltering.isToday]) plus the
|
||||||
* are overdue or due today (in [comparator] order), and those tasks as rows.
|
* overdue ones, the open ones in [comparator] order, and those tasks as rows.
|
||||||
*/
|
*/
|
||||||
internal fun todayProgress(
|
internal fun todayProgress(
|
||||||
open: List<Task>,
|
open: List<Task>,
|
||||||
@@ -58,14 +65,9 @@ internal fun todayProgress(
|
|||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
zone: ZoneId,
|
zone: ZoneId,
|
||||||
): Triple<Int, Int, List<WidgetTaskRow>> {
|
): Triple<Int, Int, List<WidgetTaskRow>> {
|
||||||
val done = completed.count { task ->
|
val (todayStart, todayEnd) = DayWindow.today(now, zone)
|
||||||
val at = task.completedAt ?: return@count false
|
val done = completed.count { TaskFiltering.isToday(it, todayStart, todayEnd, zone) }
|
||||||
at.toLocalDate(zone) == today
|
val remaining = open.filter { it.isDueToday(now, today, todayStart, todayEnd, zone) }.sortedWith(comparator)
|
||||||
}
|
|
||||||
val remaining = open.filter { task ->
|
|
||||||
val due = task.due ?: return@filter false
|
|
||||||
isOverdue(due, task.isAllDay, now, today, zone) || dueKind(due, task.isAllDay, today, zone) == DueKind.TODAY
|
|
||||||
}.sortedWith(comparator)
|
|
||||||
return Triple(done, remaining.size, remaining.take(MAX_TODAY_ROWS).map { it.toWidgetRow(now, today, zone) })
|
return Triple(done, remaining.size, remaining.take(MAX_TODAY_ROWS).map { it.toWidgetRow(now, today, zone) })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,29 +78,29 @@ internal fun upcoming(open: List<Task>, now: Instant, today: LocalDate, zone: Zo
|
|||||||
private const val MAX_UPCOMING_ROWS = 200
|
private const val MAX_UPCOMING_ROWS = 200
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Seven [WeekDay]s starting at [today]. Today's bucket folds in every overdue
|
* Seven [WeekDay]s starting at [today]. Today's bucket holds what the Today
|
||||||
* task regardless of its own due day, since that's where the app's own
|
* widget counts — the app's Today tasks plus every overdue one; every other day
|
||||||
* Overdue/Today smart lists put it; every other day matches its own due date.
|
* holds the rest by their own due date.
|
||||||
*/
|
*/
|
||||||
internal fun weekDays(open: List<Task>, comparator: Comparator<Task>, now: Instant, today: LocalDate, zone: ZoneId): List<WeekDay> =
|
internal fun weekDays(open: List<Task>, comparator: Comparator<Task>, now: Instant, today: LocalDate, zone: ZoneId): List<WeekDay> {
|
||||||
(0..6L).map { offset ->
|
val (todayStart, todayEnd) = DayWindow.today(now, zone)
|
||||||
|
val (todays, later) = open.partition { it.isDueToday(now, today, todayStart, todayEnd, zone) }
|
||||||
|
return (0..6L).map { offset ->
|
||||||
val date = today.plusDays(offset)
|
val date = today.plusDays(offset)
|
||||||
val dayTasks = open.filter { task ->
|
val dayTasks = if (offset == 0L) {
|
||||||
val due = task.due ?: return@filter false
|
todays
|
||||||
if (offset == 0L) {
|
} else {
|
||||||
isOverdue(due, task.isAllDay, now, today, zone) || due.calendarDate(task.isAllDay, zone) == date
|
later.filter { task -> task.due?.calendarDate(task.isAllDay, zone) == date }
|
||||||
} else {
|
|
||||||
due.calendarDate(task.isAllDay, zone) == date
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
val overdueCount = if (offset == 0L) {
|
val overdueCount = if (offset == 0L) {
|
||||||
dayTasks.count { isOverdue(it.due!!, it.isAllDay, now, today, zone) }
|
dayTasks.count { task -> task.due?.let { isOverdue(it, task.isAllDay, now, today, zone) } == true }
|
||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
}
|
}
|
||||||
val first = dayTasks.sortedWith(comparator).firstOrNull()?.toWidgetRow(now, today, zone)
|
val first = dayTasks.sortedWith(comparator).firstOrNull()?.toWidgetRow(now, today, zone)
|
||||||
WeekDay(date, dayTasks.size, overdueCount, first)
|
WeekDay(date, dayTasks.size, overdueCount, first)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** One tile per visible list: its open, non-subtask count — a subtask whose parent is also open is left out. */
|
/** One tile per visible list: its open, non-subtask count — a subtask whose parent is also open is left out. */
|
||||||
internal fun listTiles(open: List<Task>, lists: List<TaskList>): List<ListTile> {
|
internal fun listTiles(open: List<Task>, lists: List<TaskList>): List<ListTile> {
|
||||||
|
|||||||
@@ -55,8 +55,8 @@
|
|||||||
<string name="settings_theme">السمة</string>
|
<string name="settings_theme">السمة</string>
|
||||||
<string name="settings_theme_light">فاتح</string>
|
<string name="settings_theme_light">فاتح</string>
|
||||||
<string name="settings_theme_dark">داكن</string>
|
<string name="settings_theme_dark">داكن</string>
|
||||||
<string name="settings_section_reminders">التذكيرات</string>
|
<string name="settings_section_reminders">الإشعارات</string>
|
||||||
<string name="settings_list_notifications">الإشعارات</string>
|
<string name="settings_list_notifications">الصوت والاهتزاز</string>
|
||||||
<string name="settings_group_data">البيانات</string>
|
<string name="settings_group_data">البيانات</string>
|
||||||
<string name="settings_group_app">التطبيق</string>
|
<string name="settings_group_app">التطبيق</string>
|
||||||
<string name="settings_group_about">حول</string>
|
<string name="settings_group_about">حول</string>
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
<string name="settings_section_storage">التخزين</string>
|
<string name="settings_section_storage">التخزين</string>
|
||||||
<string name="settings_section_accounts">الحسابات</string>
|
<string name="settings_section_accounts">الحسابات</string>
|
||||||
<string name="settings_list_reminder_inherits">الافتراضي (%1$s)</string>
|
<string name="settings_list_reminder_inherits">الافتراضي (%1$s)</string>
|
||||||
<string name="settings_section_task_form">نموذج مهمة</string>
|
<string name="settings_section_task_form">نموذج مهمة جديدة</string>
|
||||||
<string name="settings_about_support">دَعم التطوير</string>
|
<string name="settings_about_support">دَعم التطوير</string>
|
||||||
<string name="settings_about_version">الإصدار %1$s</string>
|
<string name="settings_about_version">الإصدار %1$s</string>
|
||||||
<string name="settings_language">لغة التطبيق</string>
|
<string name="settings_language">لغة التطبيق</string>
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
<string name="settings_translate">المساعدة في الترجمة</string>
|
<string name="settings_translate">المساعدة في الترجمة</string>
|
||||||
<string name="settings_theme_system">اتباع النظام</string>
|
<string name="settings_theme_system">اتباع النظام</string>
|
||||||
<string name="settings_dynamic_color">اللون الديناميكي</string>
|
<string name="settings_dynamic_color">اللون الديناميكي</string>
|
||||||
<string name="settings_reminders">التذكيرات المستحقة</string>
|
<string name="settings_reminders">تذكيرات المهام</string>
|
||||||
<string name="settings_exact_alarms">التوقيت الدقيق</string>
|
<string name="settings_exact_alarms">التوقيت الدقيق</string>
|
||||||
<string name="settings_default_list">القائمة الافتراضية</string>
|
<string name="settings_default_list">القائمة الافتراضية</string>
|
||||||
<string name="settings_time_format">تنسيق الوقت</string>
|
<string name="settings_time_format">تنسيق الوقت</string>
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
<string name="settings_about_author">بواسطة Jean-Luc Makiola</string>
|
<string name="settings_about_author">بواسطة Jean-Luc Makiola</string>
|
||||||
<string name="settings_about_logo_desc">رمز تطبيق Agendula</string>
|
<string name="settings_about_logo_desc">رمز تطبيق Agendula</string>
|
||||||
<string name="settings_report_problem">الإبلاغ عن مشكلة</string>
|
<string name="settings_report_problem">الإبلاغ عن مشكلة</string>
|
||||||
<string name="settings_default_reminder">متى لتذكير</string>
|
<string name="settings_default_reminder">التذكير الافتراضي</string>
|
||||||
<string name="settings_group_look">المظهر و السلوك</string>
|
<string name="settings_group_look">المظهر و السلوك</string>
|
||||||
<string name="settings_bottom_add_bar">شريط الإضافة السريع السفلي</string>
|
<string name="settings_bottom_add_bar">شريط الإضافة السريع السفلي</string>
|
||||||
<string name="settings_time_format_12h">١٢-ساعة (٢:٠٠ م)</string>
|
<string name="settings_time_format_12h">١٢-ساعة (٢:٠٠ م)</string>
|
||||||
@@ -168,7 +168,7 @@
|
|||||||
<string name="reminder_unit_days">أيام</string>
|
<string name="reminder_unit_days">أيام</string>
|
||||||
<string name="reminder_unit_weeks">أسابيع</string>
|
<string name="reminder_unit_weeks">أسابيع</string>
|
||||||
<string name="settings_list_reminders_title">التذكيرات لكل قائمة</string>
|
<string name="settings_list_reminders_title">التذكيرات لكل قائمة</string>
|
||||||
<string name="settings_list_reminders_hint">تجاوز التذكير الافتراضي للقوائم الفردية</string>
|
<string name="settings_list_reminders_hint">تجاوز الافتراضي لكل قائمة — بشكل منفصل للمحدد بوقت ومهام طوال اليوم. يمكن لقائمة الاحتفاظ بالافتراضي، إسقاطه أو تعيين خاصّته.</string>
|
||||||
<string name="lists_reorder_handle">اسحب لإعادة الترتيب</string>
|
<string name="lists_reorder_handle">اسحب لإعادة الترتيب</string>
|
||||||
<string name="settings_list_notifications_hint">الصوت، الاهتزاز و الأهمية لتذكيرات هذه القائمة</string>
|
<string name="settings_list_notifications_hint">الصوت، الاهتزاز و الأهمية لتذكيرات هذه القائمة</string>
|
||||||
<string name="smart_today">اليوم</string>
|
<string name="smart_today">اليوم</string>
|
||||||
@@ -446,8 +446,8 @@
|
|||||||
<string name="filter_missing">تم حذف عامل التصفية</string>
|
<string name="filter_missing">تم حذف عامل التصفية</string>
|
||||||
<string name="view_filter_tags">تصفية حسب الوسم</string>
|
<string name="view_filter_tags">تصفية حسب الوسم</string>
|
||||||
<string name="filter_section_lists">القوائم</string>
|
<string name="filter_section_lists">القوائم</string>
|
||||||
<string name="settings_appearance_subtitle">السمة، اللون و تنسيق الوقت</string>
|
<string name="settings_appearance_subtitle">السمة، الألوان، تنسيق الوقت</string>
|
||||||
<string name="settings_task_form_subtitle">الحقول، القائمة والمهام الفرعية الافتراضية</string>
|
<string name="settings_task_form_subtitle">الخانات الافتراضية و السلوك</string>
|
||||||
<string name="recurrence_on_days">%1$s في %2$s</string>
|
<string name="recurrence_on_days">%1$s في %2$s</string>
|
||||||
<string name="recurrence_with_until">%1$s حتى %2$s</string>
|
<string name="recurrence_with_until">%1$s حتى %2$s</string>
|
||||||
<plurals name="recurrence_with_count">
|
<plurals name="recurrence_with_count">
|
||||||
@@ -521,7 +521,7 @@
|
|||||||
<string name="add_account_server_title">أين مهامك؟</string>
|
<string name="add_account_server_title">أين مهامك؟</string>
|
||||||
<string name="add_account_working_reading_lists">جاري قراءة قوائم المهام خاصّتك</string>
|
<string name="add_account_working_reading_lists">جاري قراءة قوائم المهام خاصّتك</string>
|
||||||
<string name="add_account_browser_title">جاري الانتظار لمتصفحك</string>
|
<string name="add_account_browser_title">جاري الانتظار لمتصفحك</string>
|
||||||
<string name="add_account_browser_use_password">استخدم كلمة مرور بدلاً</string>
|
<string name="add_account_browser_use_password">استخدم كلمة مرور بدلاً من ذلك</string>
|
||||||
<string name="add_account_browser_open">فتح صفحة تسجيل الدخول</string>
|
<string name="add_account_browser_open">فتح صفحة تسجيل الدخول</string>
|
||||||
<string name="add_account_lists_title">أيّ القوائم يجب المزامنة؟</string>
|
<string name="add_account_lists_title">أيّ القوائم يجب المزامنة؟</string>
|
||||||
<string name="add_account_provider_unsupported">لا دعم للمهام عبر CalDAV</string>
|
<string name="add_account_provider_unsupported">لا دعم للمهام عبر CalDAV</string>
|
||||||
@@ -569,7 +569,7 @@
|
|||||||
<string name="add_account_browser_error_expired">تم إغلاق نافذة الموافقة قبل أن يرد الخادم.</string>
|
<string name="add_account_browser_error_expired">تم إغلاق نافذة الموافقة قبل أن يرد الخادم.</string>
|
||||||
<string name="add_account_browser_error_rate_limited">يرفض الخادم المحاولات المتكررة. انتظر بضع دقائق وحاول مرة أخرى.</string>
|
<string name="add_account_browser_error_rate_limited">يرفض الخادم المحاولات المتكررة. انتظر بضع دقائق وحاول مرة أخرى.</string>
|
||||||
<string name="add_account_browser_error_maintenance">الخادم في وضع الصيانة. حاول مرة أخرى بمجرد عودته.</string>
|
<string name="add_account_browser_error_maintenance">الخادم في وضع الصيانة. حاول مرة أخرى بمجرد عودته.</string>
|
||||||
<string name="add_account_browser_error_unavailable">لا يحتوي هذا الجهاز على متصفح يمكنه فتح صفحة تسجيل الدخول. استخدم كلمة مرور بدلاً.</string>
|
<string name="add_account_browser_error_unavailable">لا يحتوي هذا الجهاز على متصفح يمكنه فتح صفحة تسجيل الدخول. استخدم كلمة مرور بدلاً من ذلك.</string>
|
||||||
<string name="add_account_quirk_hint_fastmail">يحتاج Fastmail كلمة مرور تطبيق، ليس كلمة مرور حسابك — وCalDAV ليس على الخطة الأساسية.</string>
|
<string name="add_account_quirk_hint_fastmail">يحتاج Fastmail كلمة مرور تطبيق، ليس كلمة مرور حسابك — وCalDAV ليس على الخطة الأساسية.</string>
|
||||||
<string name="add_account_quirk_hint_icloud">يحتاج iCloud كلمة مرور خاصة بتطبيق، التي يمكنك إنشاؤها في appleid.apple.com مع تفعيل المصادقة الثنائية.</string>
|
<string name="add_account_quirk_hint_icloud">يحتاج iCloud كلمة مرور خاصة بتطبيق، التي يمكنك إنشاؤها في appleid.apple.com مع تفعيل المصادقة الثنائية.</string>
|
||||||
<string name="add_account_setup_body">ستقوم بذلك في متصفحك، ثم العودة هنا لإكمال.</string>
|
<string name="add_account_setup_body">ستقوم بذلك في متصفحك، ثم العودة هنا لإكمال.</string>
|
||||||
@@ -580,7 +580,7 @@
|
|||||||
<string name="add_account_browser_body">وافق على Agendula في المتصفح، ثم ارجع هنا. لا يتم إرسال كلمة مرورك مطلقًا إلى هذا التطبيق — يصدر الخادم كلمة مرور تطبيق منفصلة يمكنك إلغاؤها في أي وقت.</string>
|
<string name="add_account_browser_body">وافق على Agendula في المتصفح، ثم ارجع هنا. لا يتم إرسال كلمة مرورك مطلقًا إلى هذا التطبيق — يصدر الخادم كلمة مرور تطبيق منفصلة يمكنك إلغاؤها في أي وقت.</string>
|
||||||
<string name="add_account_browser_insecure">صفحة تسجيل الدخول هذه غير مشفرة (http). أي شيء تكتبه هناك، بما في ذلك كلمة مرور حسابك، ينتقل بشكل غير مشفر.</string>
|
<string name="add_account_browser_insecure">صفحة تسجيل الدخول هذه غير مشفرة (http). أي شيء تكتبه هناك، بما في ذلك كلمة مرور حسابك، ينتقل بشكل غير مشفر.</string>
|
||||||
<string name="add_account_browser_host_mismatch">الخادم أرسلنا إلى %1$s، لكنك كتبت %2$s. يعني ذلك عادةً أن إعداد overwrite.cli.url خاصّته غير صحيح.</string>
|
<string name="add_account_browser_host_mismatch">الخادم أرسلنا إلى %1$s، لكنك كتبت %2$s. يعني ذلك عادةً أن إعداد overwrite.cli.url خاصّته غير صحيح.</string>
|
||||||
<string name="add_account_origin_mismatch">أبلغ الخادم عن عنوانه كـ %1$s، الذي لا يستطيع هذا الجهاز الوصول، لذلك تم استخدام %2$s بدلاً. من المحتمل أن يكون إعداد overwrite.cli.url أو trusted_proxies خاصّته غير صحيح.</string>
|
<string name="add_account_origin_mismatch">أبلغ الخادم عن عنوانه كـ %1$s، الذي لا يستطيع هذا الجهاز الوصول، لذلك تم استخدام %2$s بدلاً من ذلك. من المحتمل أن يكون إعداد overwrite.cli.url أو trusted_proxies خاصّته غير صحيح.</string>
|
||||||
<string name="settings_licences_subtitle">المشاريع التي بنيت Agendula عليها</string>
|
<string name="settings_licences_subtitle">المشاريع التي بنيت Agendula عليها</string>
|
||||||
<string name="licences_intro">يتضمن Agendula العمل أدناه. اضغط على أي إدخال للحصول على شفرة المصدر الخاصة به.</string>
|
<string name="licences_intro">يتضمن Agendula العمل أدناه. اضغط على أي إدخال للحصول على شفرة المصدر الخاصة به.</string>
|
||||||
<string name="licences_footer">يتم تضمين بعض من هذا الكود في شكل معدل. أين يكون هذا هو الحال، يتم إدراج كل تغيير في مستودع المصدر المرتبط أعلاه.</string>
|
<string name="licences_footer">يتم تضمين بعض من هذا الكود في شكل معدل. أين يكون هذا هو الحال، يتم إدراج كل تغيير في مستودع المصدر المرتبط أعلاه.</string>
|
||||||
@@ -661,4 +661,197 @@
|
|||||||
<string name="list_save_failed_server">الخادم لن يقبل هذا التغيير.</string>
|
<string name="list_save_failed_server">الخادم لن يقبل هذا التغيير.</string>
|
||||||
<string name="list_delete_confirm_message">سيتم حذف ”%1$s“ وجميع مهامه. هذا لا يمكن التراجع عنه.</string>
|
<string name="list_delete_confirm_message">سيتم حذف ”%1$s“ وجميع مهامه. هذا لا يمكن التراجع عنه.</string>
|
||||||
<string name="lists_empty_action">إنشاء قائمة</string>
|
<string name="lists_empty_action">إنشاء قائمة</string>
|
||||||
|
<string name="settings_dynamic_color_unavailable">متوفر على أندوريد ١٢ و أحدث</string>
|
||||||
|
<string name="settings_reminders_subtitle">التذكيرات و التسليم</string>
|
||||||
|
<string name="settings_form_fields_hint">الخانات المعروضة افتراضيًا على مهمة جديدة. الباقية تقع ضمن \"المزيد من الخانات\".</string>
|
||||||
|
<string name="settings_dynamic_color_hint">استخدام الألوان من خلفيتك</string>
|
||||||
|
<string name="settings_export_hint">حفظ قوائمك كملفات iCalendar</string>
|
||||||
|
<string name="settings_import_hint">إحضار المهام من ملف iCalendar</string>
|
||||||
|
<string name="settings_autofocus_title">تركيز العنوان على مهمة جديدة</string>
|
||||||
|
<string name="settings_autofocus_title_hint">عند بدء مهمة جديدة، ضع المؤشر في حقل العنوان وافتح لوحة المفاتيح على الفور.</string>
|
||||||
|
<string name="settings_storage_subtitle">أين مهامك تُبقى</string>
|
||||||
|
<string name="settings_allday_reminder_time">وقت تذكير اليوم الكامل</string>
|
||||||
|
<string name="settings_reliable_delivery_hint">أندرويد قد يؤخر التذكيرات لتوفير البطارية. استثنِ Agendula لكي تصل في الوقت المحدد.</string>
|
||||||
|
<string name="settings_reliable_delivery_exempt">معفى من تحسين البطارية — تصل التذكيرات في الوقت المحدّد.</string>
|
||||||
|
<string name="settings_translate_hint">إضافة أو تحسين لغة على Weblate</string>
|
||||||
|
<string name="settings_reminders_hint">أعلمني عندما تكون مهمة مستحقة</string>
|
||||||
|
<string name="settings_exact_alarms_allowed">تنطلق التذكيرات في الوقت المحدّد</string>
|
||||||
|
<string name="settings_notifications_blocked_hint">التذكيرات لا يمكنها الوصول لك. اضغط للسماح بإشعارات لـ Agendula.</string>
|
||||||
|
<string name="settings_exact_alarms_blocked">محظور — اضغط للسماح بالتذكيرات الدقيقة</string>
|
||||||
|
<string name="settings_default_list_first">أول قائمة متاحة</string>
|
||||||
|
<string name="settings_add_subtask_row">إظهار صف \"أضف مهمة فرعية\"</string>
|
||||||
|
<string name="settings_add_subtask_row_hint">خانة إضافة في نهاية المهام الفرعية للمهمة الموسعة</string>
|
||||||
|
<string name="settings_default_start_today">البداية الافتراضية إلى اليوم</string>
|
||||||
|
<string name="settings_default_start_today_hint">ملأ مسبق بداية مهمة جديدة بتاريخ اليوم</string>
|
||||||
|
<string name="settings_bottom_add_bar_hint">إضافة مهام من شريط مثبت إلى أسفل لقائمة، بدلاً من الزر العائم</string>
|
||||||
|
<string name="settings_task_store_hint">كل مخزن يحتفظ بمهامه الخاصة. التبديل لا يؤدي لنقلهم — انسخهم أولاً، أو صدِّرهم من النسخ الاحتياطي والاستعادة.</string>
|
||||||
|
<string name="settings_store_own_hint">التخزين خاصّة Agendula. لا شيء آخر للتثبيت.</string>
|
||||||
|
<string name="settings_store_external_hint">مشاركة المهام مع التطبيق الذي يزامنهم</string>
|
||||||
|
<string name="settings_store_external_missing">لا تطبيق مهام متوافق مثبّت</string>
|
||||||
|
<string name="accounts_remove_wipe">إزالة و حذف المهام</string>
|
||||||
|
<string name="list_delete_confirm_message_synced">”%1$s“ وجميع مهامه ستتم حذفها من الخادم، من كل جهاز آخر سُجِّل دخوله لهذا الحساب. هذا لا يمكن التراجع عنه.</string>
|
||||||
|
<plurals name="home_today_progress_count">
|
||||||
|
<item quantity="zero">تم %1$d من %2$d</item>
|
||||||
|
<item quantity="one">تم %1$d من %2$d</item>
|
||||||
|
<item quantity="two">تم %1$d من %2$d</item>
|
||||||
|
<item quantity="few">تم %1$d من %2$d</item>
|
||||||
|
<item quantity="many">تم %1$d من %2$d</item>
|
||||||
|
<item quantity="other">تم %1$d من %2$d</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="home_today_remaining_count">
|
||||||
|
<item quantity="zero">تبقي %1$d</item>
|
||||||
|
<item quantity="one">تبقي %1$d</item>
|
||||||
|
<item quantity="two">تبقي %1$d</item>
|
||||||
|
<item quantity="few">تبقي %1$d</item>
|
||||||
|
<item quantity="many">تبقي %1$d</item>
|
||||||
|
<item quantity="other">تبقي %1$d</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="list_where_device">على هذا الجهاز فقط</string>
|
||||||
|
<string name="list_read_only">تتم مشاركة هذه القائمة معك للقراءة فقط، لذلك فإن اسمها ولونها للمالك للتغيير.</string>
|
||||||
|
<string name="onboarding_no_provider_title">مُحتاج تطبيق مهام</string>
|
||||||
|
<string name="onboarding_permission_title">السماح بالوصول إلى مهامك</string>
|
||||||
|
<string name="onboarding_permission_body">Agendula يحتاج إذن لقراءة و كتابة مهامك. هذا هو الشيء الوحيد الذي يطلب له دائمًا.</string>
|
||||||
|
<string name="onboarding_permission_button">منح الوصول إلى المهام</string>
|
||||||
|
<string name="onboarding_welcome_sync_body">المزامنة عبر CalDAV مع الخادم أنت تختاره، أو الاحتفاظ بكل شيء على هذا الجهاز.</string>
|
||||||
|
<string name="onboarding_welcome_offline_title">لا إنترنت، بالتصميم</string>
|
||||||
|
<string name="onboarding_welcome_offline_body">هذا الإصدار من Agendula ليس لديه الوصول إلى الشبكة، لذلك فإنه لا يمكنه المزامنة أو الاتصال لأي شيء نفسه.</string>
|
||||||
|
<string name="onboarding_sync_title">مزامنة مع خادمك الخاص؟</string>
|
||||||
|
<string name="onboarding_sync_body">Agendula يدعم CalDAV — Nextcloud, Radicale, Posteo, Fastmail و أي شيء آخر يدعمه. لا حساب من خاصّتنا، أبدًا.</string>
|
||||||
|
<plurals name="widget_today_progress_cd">
|
||||||
|
<item quantity="zero">تم %1$d من %2$d</item>
|
||||||
|
<item quantity="one">تم %1$d من %2$d</item>
|
||||||
|
<item quantity="two">تم %1$d من %2$d</item>
|
||||||
|
<item quantity="few">تم %1$d من %2$d</item>
|
||||||
|
<item quantity="many">تم %1$d من %2$d</item>
|
||||||
|
<item quantity="other">تم %1$d من %2$d</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="sync_notice_more">
|
||||||
|
<item quantity="zero">و (%1$d) لا أخرى</item>
|
||||||
|
<item quantity="one">و %1$d آخر</item>
|
||||||
|
<item quantity="two">و %1$d آخران</item>
|
||||||
|
<item quantity="few">و %1$d أخرى</item>
|
||||||
|
<item quantity="many">و %1$d آخر</item>
|
||||||
|
<item quantity="other">و %1$d آخر</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="onboarding_no_provider_body">أنت اخترت للاحتفاظ بالمهام في تطبيق آخر، لكن لا واحد متوافق مثبّت. قم بتثبيت OpenTasks أو tasks.org، أو قم بالتبديل عودتًا إلى التخزين خاصّة Agendula.</string>
|
||||||
|
<string name="onboarding_sync_connect">ربط حساب</string>
|
||||||
|
<string name="onboarding_sync_devices_title">كل جهاز في وقت واحد</string>
|
||||||
|
<string name="onboarding_sync_devices_body">ضع علامة صح لشيء ما هنا و سيتم وضع العلامة عليه في كل مكان.</string>
|
||||||
|
<string name="onboarding_sync_copy_title">نسخة تبقَى بعد الهاتف</string>
|
||||||
|
<string name="onboarding_sync_copy_body">تعيش مهامك على الخادم أيضًا، لذلك جهاز مفقود لا يعني فقدان العمل.</string>
|
||||||
|
<string name="onboarding_sync_yours_title">لك الاختيار</string>
|
||||||
|
<string name="onboarding_sync_yours_body">مستضاف ذاتيًا (Self-hosted) أو مزود أنت تدفع له بالفعل — Agendula فقط يخزن كلمة المرور.</string>
|
||||||
|
<string name="onboarding_list_title">قائمتك الأولى</string>
|
||||||
|
<string name="onboarding_list_body">تحافظ القوائم على فصل الأشياء. ابدأ بواحدة — يمكنك إضافة المزيد، وإعادة تسمية هذه القائمة، أيًّا كان الوقت.</string>
|
||||||
|
<string name="onboarding_backup_title">احتفظ بنسخة</string>
|
||||||
|
<string name="settings_section_backup">النسخ الاحتياطي والاستعادة</string>
|
||||||
|
<string name="onboarding_backup_body_later">دون حساب، قوائمك تعيش فقط على هذا الجهاز. احفظهم كملفات iCalendar أي تطبيق مهام أو تقويم آخر يمكنه قراءتها، أيّا كان الوقت من الإعدادات ← النسخ الاحتياطي والاستعادة ← تصدير.</string>
|
||||||
|
<string name="onboarding_quick_add_title">كيف ترغب في إضافة المهام؟</string>
|
||||||
|
<string name="onboarding_quick_add_bar">شريط الإضافة السريع</string>
|
||||||
|
<string name="onboarding_quick_add_bar_hint">خانة في أسفل كل قائمة: اكتب، اضغط إدخال، إلى التالي.</string>
|
||||||
|
<string name="onboarding_quick_add_button">زر مهمة جديدة</string>
|
||||||
|
<string name="onboarding_quick_add_button_hint">زر عائم يفتح النموذج الكامل.</string>
|
||||||
|
<string name="onboarding_exact_alarms_title">صحيح على الوقت</string>
|
||||||
|
<string name="onboarding_exact_alarms_body">أندوريد يسأل قبل أن يتمكن تطبيق من إعلامك في دقيقة محددة. اسمح به والتذكيرات تصل في موعد الاستحقاق خاصّتهم — بدونه قد يتأخروا بضع دقائق.</string>
|
||||||
|
<string name="onboarding_exact_alarms_allow">السماح بالتوقيت الدقيق</string>
|
||||||
|
<string name="onboarding_use_own_store">استخدم تخزين هذا الجهاز بدلاً من ذلك</string>
|
||||||
|
<string name="widget_config_theme_follow_app">اتبع التطبيق</string>
|
||||||
|
<string name="widget_config_theme_light">فاتح</string>
|
||||||
|
<string name="widget_config_theme_dark">داكن</string>
|
||||||
|
<string name="widget_config_row_color">ألوان خام</string>
|
||||||
|
<string name="widget_config_row_color_neutral">محايد</string>
|
||||||
|
<string name="widget_config_background">الخلفية</string>
|
||||||
|
<string name="widget_config_background_solid">سادة</string>
|
||||||
|
<string name="widget_config_theme">السمة</string>
|
||||||
|
<string name="app_name_tasks_alias">مهام</string>
|
||||||
|
<string name="list_where">أين</string>
|
||||||
|
<string name="settings_backup_subtitle">التصدير و الاستيراد</string>
|
||||||
|
<string name="error_reminder_without_date">اضبط تاريخ بدء أو استحقاق لاستخدام تذكير.</string>
|
||||||
|
<string name="reminder_at_start">في وقت البدء</string>
|
||||||
|
<string name="reminder_at_due_time">في وقت الاستحقاق</string>
|
||||||
|
<string name="reminder_on_start_date">عند موعد البدء</string>
|
||||||
|
<string name="reminder_on_due_date">عند موعد الاستحقاق</string>
|
||||||
|
<string name="reminder_at_time">%1$s عند %2$s</string>
|
||||||
|
<string name="reminder_anchor_start">من البدء</string>
|
||||||
|
<string name="reminder_anchor_due">من تاريخ الاستحقاق</string>
|
||||||
|
<string name="reminder_time">الوقت</string>
|
||||||
|
<string name="reminder_benefit_delivery_title">تنبيه عندما يكون الأمر مهمًا</string>
|
||||||
|
<string name="reminder_benefit_reversible_title">يمكنك تغييره في أي وقت</string>
|
||||||
|
<string name="settings_app_name_hint">اعرض Agendula كـ ”مهام“ في مشغّل التطبيقات الخاص بك. فقط الاسم في المشغّل يتغير؛ وقد ينتقل الرمز إلى مكان جديد. يُغلَق Agendula عندما تقوم بالتبديل، لذا أعِد فتحه من المشغّل.</string>
|
||||||
|
<string name="settings_allday_reminder_time_summary">العد التنازلي لمهام التي تستمر طوال اليوم عند %1$s</string>
|
||||||
|
<string name="settings_store_permission_denied_hint">مهام التطبيق الآخر تبقى غير قابلة للوصول حتى تسمح بالوصول. اضغط لفتح إعدادات التطبيق.</string>
|
||||||
|
<string name="settings_copy_counting">جارٍ حساب ما هناك لنسخه…</string>
|
||||||
|
<string name="settings_copy_confirm_empty">%1$s لا يحتوي على مهام لنسخها.</string>
|
||||||
|
<string name="settings_copy_nothing">لم يكن هناك شيء للنسخ</string>
|
||||||
|
<string name="export_hint">ملف .ics واحد لكل قائمة، قابل للقراءة بواسطة تطبيقات المهام والتقويم الأخرى. القوائم الموضوع عليها بعلامة صح تذهب إلى مجلد تختاره أنت، أو إلى ملف مضغوط (zip) واحد.</string>
|
||||||
|
<string name="export_no_lists">لا توجد قوائم للتصدير</string>
|
||||||
|
<string name="export_to_folder">الحفظ إلى مجلد</string>
|
||||||
|
<string name="export_to_zip">حفظ كملف مضغوط (zip)</string>
|
||||||
|
<plurals name="export_done">
|
||||||
|
<item quantity="zero">تم تصدير (%1$d) لا قوائم</item>
|
||||||
|
<item quantity="one">تم تصدير قائمة واحدة (%1$d)</item>
|
||||||
|
<item quantity="two">تم تصدير قائمتان (%1$d)</item>
|
||||||
|
<item quantity="few">تم تصدير %1$d قوائم</item>
|
||||||
|
<item quantity="many">تم تصدير %1$d قائمة</item>
|
||||||
|
<item quantity="other">تم تصدير %1$d قائمة</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_minutes_before_start">
|
||||||
|
<item quantity="zero">(%1$d) لا دقائق قبل البدء</item>
|
||||||
|
<item quantity="one">%1$d دقيقة واحده قبل البدء</item>
|
||||||
|
<item quantity="two">%1$d دقيقتان قبل البدء</item>
|
||||||
|
<item quantity="few">%1$d دقائق قبل البدء</item>
|
||||||
|
<item quantity="many">%1$d دقيقة قبل البدء</item>
|
||||||
|
<item quantity="other">%1$d دقيقة قبل البدء</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_hours_before_start">
|
||||||
|
<item quantity="zero">(%1$d) لا ساعات قبل البدء</item>
|
||||||
|
<item quantity="one">%1$d ساعة واحده قبل البدء</item>
|
||||||
|
<item quantity="two">%1$d ساعتان قبل البدء</item>
|
||||||
|
<item quantity="few">%1$d ساعات قبل البدء</item>
|
||||||
|
<item quantity="many">%1$d ساعة قبل البدء</item>
|
||||||
|
<item quantity="other">%1$d ساعة قبل البدء</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_days_before_start">
|
||||||
|
<item quantity="zero">(%1$d) لا أيام قبل البدء</item>
|
||||||
|
<item quantity="one">%1$d يوم واحد قبل البدء</item>
|
||||||
|
<item quantity="two">%1$d يومان قبل البدء</item>
|
||||||
|
<item quantity="few">%1$d أيام قبل البدء</item>
|
||||||
|
<item quantity="many">%1$d يوم قبل البدء</item>
|
||||||
|
<item quantity="other">%1$d يوم قبل البدء</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_weeks_before_start">
|
||||||
|
<item quantity="zero">(%1$d) لا أسابيع قبل البدء</item>
|
||||||
|
<item quantity="one">%1$d أسبوع واحد قبل البدء</item>
|
||||||
|
<item quantity="two">%1$d أسبوعان قبل البدء</item>
|
||||||
|
<item quantity="few">%1$d أسابيع قبل البدء</item>
|
||||||
|
<item quantity="many">%1$d أسبوع قبل البدء</item>
|
||||||
|
<item quantity="other">%1$d أسبوع قبل البدء</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="settings_accounts_subtitle">مزامنة مهامك مع خادم CalDAV</string>
|
||||||
|
<string name="accounts_add">إضافة حسابًا</string>
|
||||||
|
<string name="accounts_sync_interval">المزامنة تلقائيًا</string>
|
||||||
|
<string name="accounts_sync_interval_manual">فقط عند تقوم بالمزامنة</string>
|
||||||
|
<string name="accounts_push_via">تصل التغييرات فورًا، عبر %1$s</string>
|
||||||
|
<string name="widget_unavailable">افتح Agendula لإنهاء الإعداد</string>
|
||||||
|
<string name="widget_today_all_done">انتهى كل شيء</string>
|
||||||
|
<string name="widget_config_title">إظهار في الأداة المصغرة</string>
|
||||||
|
<string name="widget_config_group_by_list">تجميع حسب القائمة</string>
|
||||||
|
<string name="widget_config_group_by_list_hint">إظهار مهام كل قائمة ضمن عنوانها الخاص</string>
|
||||||
|
<string name="widget_config_background_solid_hint">يملأ الأداة المصغّرة بلون سطح السمة</string>
|
||||||
|
<string name="widget_config_background_tinted_hint">أكثر نعومة، تعبئة لونية متناغمة</string>
|
||||||
|
<string name="widget_config_background_transparent">شفافة</string>
|
||||||
|
<string name="widget_config_background_transparent_hint">لا تعبئة — تبقى المهام قابلة للقراءة في حاوياتها الخاصة</string>
|
||||||
|
<string name="widget_config_row_color_neutral_hint">يستخدم كل صف نفس التعبئة اللونية المتناغمة</string>
|
||||||
|
<string name="widget_config_row_color_by_list">حسب القائمة</string>
|
||||||
|
<string name="widget_config_row_color_by_list_hint">كل صف متناغم باللون الخاص بقائمته</string>
|
||||||
|
<string name="widget_today_description">حلقة تقدم لمهام اليوم</string>
|
||||||
|
<string name="widget_up_next_description">مهمتك الوحيدة الأقرب لموعد الاستحقاق</string>
|
||||||
|
<string name="widget_lists_label">قوائم</string>
|
||||||
|
<string name="widget_lists_description">مربع اختصار لكل قائمة، مع عدد مرّات فتحه</string>
|
||||||
|
<string name="widget_week_label">أسبوع</string>
|
||||||
|
<string name="widget_week_description">شريط من سبعة أيام لعدد المهام</string>
|
||||||
|
<string name="widget_config_background_tinted">ملونة</string>
|
||||||
|
<string name="settings_theme_black">أسود</string>
|
||||||
|
<string name="settings_theme_black_summary">داكن مع خلفيات سوداء نقية، لشاشات AMOLED</string>
|
||||||
|
<string name="settings_app_name">اسم التطبيق</string>
|
||||||
</resources>
|
</resources>
|
||||||
Loaded 100 of 166 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user