Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1abcecd06 | ||
|
|
ba48a9821c | ||
|
|
fd8e4c9450 | ||
|
|
3452fd631b | ||
|
|
b8264e336f | ||
|
|
2c450d300f | ||
|
|
df1fb4e46b | ||
|
|
63c7aa64e2 | ||
|
|
c26ff5909b | ||
|
|
c1219a434d | ||
|
|
5dc1d49295 |
@@ -7,6 +7,24 @@ All notable changes to this project are documented here. The format follows
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 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 (#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).
|
||||||
|
|
||||||
## [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 = 1030001
|
||||||
versionName = "1.2.0"
|
versionName = "1.3.0-beta.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')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
@@ -147,6 +147,21 @@ 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val TEST_DB = "migration-test.db"
|
const val TEST_DB = "migration-test.db"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ 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
|
||||||
@@ -88,15 +87,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 +104,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,6 +153,7 @@ 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 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 +162,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,6 +223,7 @@ 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 },
|
||||||
|
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 +238,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)
|
||||||
.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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,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 +85,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,7 +95,10 @@ 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)
|
||||||
@@ -240,3 +245,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,7 +20,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 +61,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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ 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, l.sort_order, l.name
|
||||||
"""
|
"""
|
||||||
@@ -25,7 +25,7 @@ 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
|
||||||
WHERE l.id = :listId
|
WHERE l.id = :listId
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import kotlin.time.Instant
|
|||||||
TaskEntity::class,
|
TaskEntity::class,
|
||||||
TaskAlarmEntity::class,
|
TaskAlarmEntity::class,
|
||||||
],
|
],
|
||||||
version = 3,
|
version = 4,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
@TypeConverters(Converters::class)
|
@TypeConverters(Converters::class)
|
||||||
@@ -173,5 +173,12 @@ 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ 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
|
||||||
@@ -46,8 +51,19 @@ 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) }
|
||||||
|
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) {
|
||||||
@@ -71,6 +87,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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -21,8 +28,13 @@ 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.
|
||||||
|
* [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 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
|
||||||
|
|||||||
@@ -14,13 +14,16 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
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.GroupedHint
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
@@ -38,6 +41,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 +95,26 @@ 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) {
|
||||||
|
OptionPicker(
|
||||||
|
title = stringResource(R.string.settings_app_name),
|
||||||
|
options = LauncherName.entries,
|
||||||
|
selected = launcherName,
|
||||||
|
label = { launcherNameLabel(it) },
|
||||||
|
header = { GroupedHint(stringResource(R.string.settings_app_name_hint)) },
|
||||||
|
onSelect = viewModel::setLauncherName,
|
||||||
|
onDismiss = { showAppName = false },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showTimeFormat) {
|
if (showTimeFormat) {
|
||||||
@@ -140,6 +165,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 +189,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() {
|
||||||
|
|
||||||
@@ -217,6 +221,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
|
||||||
@@ -371,8 +383,6 @@ private fun TaskListBody(
|
|||||||
// 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 +430,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 +662,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 +1044,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 +1134,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) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -328,7 +328,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,
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Agendula</string>
|
<string name="app_name">Agendula</string>
|
||||||
|
<string name="app_name_tasks_alias">Tasks</string>
|
||||||
|
|
||||||
<!-- Generic -->
|
<!-- Generic -->
|
||||||
<string name="task_untitled">Untitled task</string>
|
<string name="task_untitled">Untitled task</string>
|
||||||
@@ -448,6 +449,10 @@
|
|||||||
<string name="settings_theme_system">Follow system</string>
|
<string name="settings_theme_system">Follow system</string>
|
||||||
<string name="settings_theme_light">Light</string>
|
<string name="settings_theme_light">Light</string>
|
||||||
<string name="settings_theme_dark">Dark</string>
|
<string name="settings_theme_dark">Dark</string>
|
||||||
|
<string name="settings_theme_black">Black</string>
|
||||||
|
<string name="settings_theme_black_summary">Dark with pure black backgrounds, for AMOLED screens</string>
|
||||||
|
<string name="settings_app_name">App name</string>
|
||||||
|
<string name="settings_app_name_hint">Show Agendula as “Tasks” in your launcher. Only the launcher name changes; the icon may move to a new spot. Agendula closes when you switch, so reopen it from the launcher.</string>
|
||||||
<string name="settings_dynamic_color">Dynamic colour</string>
|
<string name="settings_dynamic_color">Dynamic colour</string>
|
||||||
<string name="settings_dynamic_color_hint">Use colours from your wallpaper</string>
|
<string name="settings_dynamic_color_hint">Use colours from your wallpaper</string>
|
||||||
<string name="settings_section_reminders">Notifications</string>
|
<string name="settings_section_reminders">Notifications</string>
|
||||||
@@ -681,6 +686,11 @@
|
|||||||
<string name="accounts_remove_wipe_body">Also deletes this account\u2019s lists and tasks from this device.</string>
|
<string name="accounts_remove_wipe_body">Also deletes this account\u2019s lists and tasks from this device.</string>
|
||||||
<string name="accounts_never_synced">Never synced</string>
|
<string name="accounts_never_synced">Never synced</string>
|
||||||
<string name="accounts_last_sync">Last sync</string>
|
<string name="accounts_last_sync">Last sync</string>
|
||||||
|
<string name="accounts_name">Name</string>
|
||||||
|
<string name="accounts_name_hint">How this account is shown in Agendula. Leave it empty to use “%1$s”.</string>
|
||||||
|
<string name="accounts_connection">Connection</string>
|
||||||
|
<string name="accounts_connection_cleartext">Unencrypted (http)</string>
|
||||||
|
<string name="accounts_trusted_certificate">Trusted certificate</string>
|
||||||
<string name="accounts_generic_provider">CalDAV account</string>
|
<string name="accounts_generic_provider">CalDAV account</string>
|
||||||
<string name="accounts_sync_failed">Last sync didn\u2019t finish</string>
|
<string name="accounts_sync_failed">Last sync didn\u2019t finish</string>
|
||||||
<string name="accounts_failed_sign_in">Sync failed \u2014 the server didn\u2019t accept the password</string>
|
<string name="accounts_failed_sign_in">Sync failed \u2014 the server didn\u2019t accept the password</string>
|
||||||
@@ -730,6 +740,7 @@
|
|||||||
<string name="add_account_error_insecure">That address is unencrypted. Agendula only sends your password over https.</string>
|
<string name="add_account_error_insecure">That address is unencrypted. Agendula only sends your password over https.</string>
|
||||||
<string name="add_account_error_no_calendars">Signed in, but this account has no task lists.</string>
|
<string name="add_account_error_no_calendars">Signed in, but this account has no task lists.</string>
|
||||||
<string name="add_account_error_server">The server ran into a problem. Try again in a moment.</string>
|
<string name="add_account_error_server">The server ran into a problem. Try again in a moment.</string>
|
||||||
|
<string name="add_account_error_tls">Couldn\u2019t set up a secure connection to that server.</string>
|
||||||
<string name="add_account_working_discovering">Looking for a CalDAV server</string>
|
<string name="add_account_working_discovering">Looking for a CalDAV server</string>
|
||||||
<string name="add_account_working_signing_in">Signing in</string>
|
<string name="add_account_working_signing_in">Signing in</string>
|
||||||
<string name="add_account_working_reading_lists">Reading your task lists</string>
|
<string name="add_account_working_reading_lists">Reading your task lists</string>
|
||||||
@@ -808,6 +819,21 @@
|
|||||||
<string name="add_account_browser_host_mismatch">The server sent us to %1$s, but you typed %2$s. That usually means its overwrite.cli.url setting is wrong.</string>
|
<string name="add_account_browser_host_mismatch">The server sent us to %1$s, but you typed %2$s. That usually means its overwrite.cli.url setting is wrong.</string>
|
||||||
<string name="add_account_origin_mismatch">The server reported its address as %1$s, which this device cannot reach, so %2$s was used instead. Its overwrite.cli.url or trusted_proxies setting is probably wrong.</string>
|
<string name="add_account_origin_mismatch">The server reported its address as %1$s, which this device cannot reach, so %2$s was used instead. Its overwrite.cli.url or trusted_proxies setting is probably wrong.</string>
|
||||||
|
|
||||||
|
<string name="add_account_cleartext_title">Connect without encryption?</string>
|
||||||
|
<string name="add_account_cleartext_body">This address uses http, not https. Your password and your tasks would travel unencrypted, and anyone on the network between this phone and the server could read them.</string>
|
||||||
|
<string name="add_account_cleartext_note">That can be fine for a server you only reach over a VPN or your home network. Anywhere else, set up https on the server instead.</string>
|
||||||
|
<string name="add_account_cleartext_connect">Connect anyway</string>
|
||||||
|
<string name="add_account_change_address">Change the address</string>
|
||||||
|
|
||||||
|
<string name="add_account_certificate_title">Trust this certificate?</string>
|
||||||
|
<string name="add_account_certificate_body">This server\u2019s certificate isn\u2019t signed by an authority your phone trusts. If you run this server yourself, compare the fingerprint below with your server\u2019s certificate before trusting it.</string>
|
||||||
|
<string name="add_account_certificate_fingerprint">SHA-256 fingerprint</string>
|
||||||
|
<string name="add_account_certificate_subject">Issued to</string>
|
||||||
|
<string name="add_account_certificate_issuer">Issued by</string>
|
||||||
|
<string name="add_account_certificate_expires">Valid until</string>
|
||||||
|
<string name="add_account_certificate_trust">Trust this certificate</string>
|
||||||
|
<string name="add_account_certificate_decline">Don\u2019t trust it</string>
|
||||||
|
|
||||||
<string name="add_account_lists_title">Which lists should sync?</string>
|
<string name="add_account_lists_title">Which lists should sync?</string>
|
||||||
<string name="add_account_lists_read_only">Read-only</string>
|
<string name="add_account_lists_read_only">Read-only</string>
|
||||||
<string name="add_account_lists_shared">Shared with you</string>
|
<string name="add_account_lists_shared">Shared with you</string>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import dagger.Module
|
|||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
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.sync.RemoteLists.Outcome
|
import de.jeanlucmakiola.agendula.data.sync.RemoteLists.Outcome
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
|
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
|
||||||
@@ -26,6 +27,9 @@ object SyncModule {
|
|||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
fun provideRemoteLists(): RemoteLists = NoRemoteLists
|
fun provideRemoteLists(): RemoteLists = NoRemoteLists
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideManualSync(): ManualSync = ManualSync.None
|
||||||
}
|
}
|
||||||
|
|
||||||
private object NoRemoteLists : RemoteLists {
|
private object NoRemoteLists : RemoteLists {
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.appname
|
||||||
|
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class LauncherNameManagerTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an enabled Tasks alias reads as Tasks`() {
|
||||||
|
assertThat(launcherNameFor(PackageManager.COMPONENT_ENABLED_STATE_ENABLED)).isEqualTo(LauncherName.TASKS)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a default or disabled Tasks alias reads as Agendula`() {
|
||||||
|
assertThat(launcherNameFor(PackageManager.COMPONENT_ENABLED_STATE_DEFAULT)).isEqualTo(LauncherName.AGENDULA)
|
||||||
|
assertThat(launcherNameFor(PackageManager.COMPONENT_ENABLED_STATE_DISABLED)).isEqualTo(LauncherName.AGENDULA)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `switching always enables the target before disabling the other`() {
|
||||||
|
assertThat(aliasWritePlan(LauncherName.TASKS)).containsExactly(
|
||||||
|
AliasStateChange(LauncherAlias.TASKS, enabled = true),
|
||||||
|
AliasStateChange(LauncherAlias.DEFAULT, enabled = false),
|
||||||
|
).inOrder()
|
||||||
|
assertThat(aliasWritePlan(LauncherName.AGENDULA)).containsExactly(
|
||||||
|
AliasStateChange(LauncherAlias.DEFAULT, enabled = true),
|
||||||
|
AliasStateChange(LauncherAlias.TASKS, enabled = false),
|
||||||
|
).inOrder()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.reminders
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class ExpandedTextTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `no description means no expanded body`() {
|
||||||
|
assertThat(expandedText("Due today", null)).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `description alone when there is no due line`() {
|
||||||
|
assertThat(expandedText(null, "Buy oat milk")).isEqualTo("Buy oat milk")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `due line comes first, separated by a blank line`() {
|
||||||
|
assertThat(expandedText("Due today", "Buy oat milk")).isEqualTo("Due today\n\nBuy oat milk")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,4 +27,16 @@ class RoomTaskMapperTest {
|
|||||||
|
|
||||||
assertThat(RoomTaskMapper.taskList(list).acceptsWrites).isFalse()
|
assertThat(RoomTaskMapper.taskList(list).acceptsWrites).isFalse()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an account's label is shown in place of its stored name`() {
|
||||||
|
val list = TaskListRow(
|
||||||
|
list = TaskListEntity(id = 2, name = "Work", color = 0, accountId = 1),
|
||||||
|
accountDisplayName = "me@host",
|
||||||
|
accountLabel = "Work server",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(RoomTaskMapper.taskList(list).accountName).isEqualTo("Work server")
|
||||||
|
assertThat(RoomTaskMapper.taskList(list.copy(accountLabel = null)).accountName).isEqualTo("me@host")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -210,6 +210,7 @@ class WidgetDataTest {
|
|||||||
fun `no override resolves to whatever the app's own theme means`() {
|
fun `no override resolves to whatever the app's own theme means`() {
|
||||||
assertThat(readyWithTheme(ThemeMode.DARK).effectiveThemeMode(null)).isEqualTo(WidgetThemeMode.DARK)
|
assertThat(readyWithTheme(ThemeMode.DARK).effectiveThemeMode(null)).isEqualTo(WidgetThemeMode.DARK)
|
||||||
assertThat(readyWithTheme(ThemeMode.SYSTEM).effectiveThemeMode(null)).isEqualTo(WidgetThemeMode.SYSTEM)
|
assertThat(readyWithTheme(ThemeMode.SYSTEM).effectiveThemeMode(null)).isEqualTo(WidgetThemeMode.SYSTEM)
|
||||||
|
assertThat(readyWithTheme(ThemeMode.BLACK).effectiveThemeMode(null)).isEqualTo(WidgetThemeMode.DARK)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.sync
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `network_security_config.xml` permits cleartext app-wide, and the per-account
|
||||||
|
* opt-in is enforced only by the clients `CalDavHttp` builds. Any HTTP client
|
||||||
|
* built elsewhere would speak plain http without asking.
|
||||||
|
*/
|
||||||
|
class HttpClientConstructionTest {
|
||||||
|
|
||||||
|
private val forbidden = listOf(
|
||||||
|
"OkHttpClient()",
|
||||||
|
"OkHttpClient.Builder()",
|
||||||
|
"HttpURLConnection",
|
||||||
|
"HttpsURLConnection",
|
||||||
|
".openConnection(",
|
||||||
|
".openStream(",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every HTTP client is built through CalDavHttp`() {
|
||||||
|
val roots = listOf(File("src"), File("../caldav/src/main"))
|
||||||
|
assertThat(roots.filter { it.isDirectory }).isNotEmpty()
|
||||||
|
|
||||||
|
val offenders = roots
|
||||||
|
.filter { it.isDirectory }
|
||||||
|
.flatMap { root -> root.walk().filter { it.isFile && it.extension in setOf("kt", "java") }.toList() }
|
||||||
|
.filterNot { "/test" in it.invariantSeparatorsPath || "/androidTest" in it.invariantSeparatorsPath }
|
||||||
|
.filterNot { it.name == "CalDavHttp.kt" }
|
||||||
|
.flatMap { file ->
|
||||||
|
file.readLines().mapIndexedNotNull { index, line ->
|
||||||
|
forbidden.firstOrNull { it in line }?.let { "${file.path}:${index + 1}: $it" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThat(offenders).isEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.sync
|
||||||
|
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.caldav.ServerTrust
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.jupiter.api.AfterEach
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.io.TempDir
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/** Per-account trust: pinned certificates and the plain-http opt-in. */
|
||||||
|
class ServerTrustStoreTest {
|
||||||
|
|
||||||
|
@TempDir lateinit var directory: File
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
|
||||||
|
|
||||||
|
private val dataStore: DataStore<Preferences> by lazy {
|
||||||
|
PreferenceDataStoreFactory.create(scope = scope) { File(directory, "trust.preferences_pb") }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val store by lazy { ServerTrustStore(dataStore) }
|
||||||
|
|
||||||
|
@AfterEach fun tearDown() = scope.cancel()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an account nobody trusted anything for gets the platform's defaults`() = runTest {
|
||||||
|
assertThat(store.get(1)).isEqualTo(ServerTrust.DEFAULT)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `what is put comes back, per account`() = runTest {
|
||||||
|
val trust = ServerTrust(pinnedCertificates = setOf("ab", "cd"), allowCleartext = true)
|
||||||
|
store.put(1, trust)
|
||||||
|
store.put(2, ServerTrust(pinnedCertificates = setOf("ef")))
|
||||||
|
|
||||||
|
assertThat(store.get(1)).isEqualTo(trust)
|
||||||
|
assertThat(store.get(2)).isEqualTo(ServerTrust(pinnedCertificates = setOf("ef")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `forgetting one account leaves another alone`() = runTest {
|
||||||
|
store.put(1, ServerTrust(allowCleartext = true))
|
||||||
|
store.put(2, ServerTrust(allowCleartext = true))
|
||||||
|
store.forget(1)
|
||||||
|
|
||||||
|
assertThat(store.get(1)).isEqualTo(ServerTrust.DEFAULT)
|
||||||
|
assertThat(store.get(2).allowCleartext).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
+264
-7
@@ -9,7 +9,9 @@ import de.jeanlucmakiola.agendula.data.sync.LoginFlowRecord
|
|||||||
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.TaskCollection
|
import de.jeanlucmakiola.caldav.TaskCollection
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -1307,6 +1309,15 @@ class AddAccountViewModelTest {
|
|||||||
assertThat(vm.state.value.fatal).isEqualTo(AddAccountMessage.ExternalStorage)
|
assertThat(vm.state.value.fatal).isEqualTo(AddAccountMessage.ExternalStorage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a server on its own port is named with it`() = runTest(dispatcher) {
|
||||||
|
val vm = signedIn("https://cloud.example.com:5233/")
|
||||||
|
vm.onSave()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertThat(creator.created).containsExactly("me@cloud.example.com:5233")
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a completed add reports the account it created`() = runTest(dispatcher) {
|
fun `a completed add reports the account it created`() = runTest(dispatcher) {
|
||||||
val vm = signedIn()
|
val vm = signedIn()
|
||||||
@@ -1400,18 +1411,250 @@ class AddAccountViewModelTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
inner class TrustingTheServer {
|
||||||
|
|
||||||
|
private val certificate = ServerCertificate(
|
||||||
|
fingerprint = "ab".repeat(32),
|
||||||
|
subject = "CN=nas.home.arpa",
|
||||||
|
issuer = "CN=nas.home.arpa",
|
||||||
|
notAfterMillis = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a typed http address asks before anything is sent`() = runTest(dispatcher) {
|
||||||
|
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.Failed(
|
||||||
|
CalDavDiscovery.Outcome.Cause.INSECURE,
|
||||||
|
"http",
|
||||||
|
)
|
||||||
|
val vm = viewModel()
|
||||||
|
vm.enterAddress("http://nas.local:5232")
|
||||||
|
vm.onAddressSubmitted()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertThat(vm.state.value.step)
|
||||||
|
.isEqualTo(AddAccountStep.ConfirmCleartext("http://nas.local:5232"))
|
||||||
|
|
||||||
|
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList())
|
||||||
|
vm.onCleartextConfirmed()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertThat(gateway.discoveries.last().trust.allowCleartext).isTrue()
|
||||||
|
assertThat(vm.state.value.step).isInstanceOf(AddAccountStep.EnterCredentials::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `declining plain http goes back to the address`() = runTest(dispatcher) {
|
||||||
|
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.Failed(
|
||||||
|
CalDavDiscovery.Outcome.Cause.INSECURE,
|
||||||
|
"http",
|
||||||
|
)
|
||||||
|
val vm = viewModel()
|
||||||
|
vm.enterAddress("http://nas.local")
|
||||||
|
vm.onAddressSubmitted()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertThat(vm.onBackWithin()).isTrue()
|
||||||
|
assertThat((vm.state.value.step as AddAccountStep.EnterAddress).input).isEqualTo("http://nas.local")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an https downgrade is still an error, not a question`() = runTest(dispatcher) {
|
||||||
|
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.Failed(
|
||||||
|
CalDavDiscovery.Outcome.Cause.INSECURE,
|
||||||
|
"home set downgrades",
|
||||||
|
)
|
||||||
|
val vm = viewModel()
|
||||||
|
vm.enterAddress("https://cloud.example.com")
|
||||||
|
vm.onAddressSubmitted()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertThat((vm.state.value.step as AddAccountStep.EnterAddress).error)
|
||||||
|
.isEqualTo(CalDavDiscovery.Outcome.Cause.INSECURE)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a trusted certificate is pinned and the discovery re-run`() = runTest(dispatcher) {
|
||||||
|
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.UntrustedCertificate(certificate)
|
||||||
|
val vm = viewModel()
|
||||||
|
vm.enterAddress("https://nas.home.arpa")
|
||||||
|
vm.onAddressSubmitted()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertThat(vm.state.value.step).isEqualTo(AddAccountStep.TrustCertificate(certificate))
|
||||||
|
|
||||||
|
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList())
|
||||||
|
vm.onCertificateTrusted()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertThat(gateway.discoveries.last().trust.pinnedCertificates)
|
||||||
|
.containsExactly(certificate.fingerprint)
|
||||||
|
assertThat(vm.state.value.step).isInstanceOf(AddAccountStep.EnterCredentials::class.java)
|
||||||
|
|
||||||
|
// And it travels with the account.
|
||||||
|
gateway.discoveryOutcomes += found(collection("Tasks"))
|
||||||
|
vm.onUsernameChanged("me")
|
||||||
|
vm.onPasswordChanged("pw")
|
||||||
|
vm.onCredentialsSubmitted()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertThat(gateway.discoveries.last().trust.pinnedCertificates)
|
||||||
|
.containsExactly(certificate.fingerprint)
|
||||||
|
vm.onSave()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertThat(creator.savedTrust.single().pinnedCertificates).containsExactly(certificate.fingerprint)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a certificate asked about after sign-in returns to the password`() = runTest(dispatcher) {
|
||||||
|
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList())
|
||||||
|
gateway.loginFlow = null
|
||||||
|
val vm = viewModel()
|
||||||
|
vm.enterAddress("https://cloud.example.com/")
|
||||||
|
vm.onAddressSubmitted()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.UntrustedCertificate(certificate)
|
||||||
|
vm.onUsernameChanged("me")
|
||||||
|
vm.onPasswordChanged("pw")
|
||||||
|
vm.onCredentialsSubmitted()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertThat(vm.state.value.step).isInstanceOf(AddAccountStep.TrustCertificate::class.java)
|
||||||
|
|
||||||
|
assertThat(vm.onBackWithin()).isTrue()
|
||||||
|
assertThat((vm.state.value.step as AddAccountStep.EnterCredentials).username).isEqualTo("me")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a certificate trusted for one address is not carried to another`() = runTest(dispatcher) {
|
||||||
|
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.UntrustedCertificate(certificate)
|
||||||
|
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList())
|
||||||
|
gateway.loginFlow = null
|
||||||
|
val vm = viewModel()
|
||||||
|
vm.enterAddress("https://a.lan")
|
||||||
|
vm.onAddressSubmitted()
|
||||||
|
advanceUntilIdle()
|
||||||
|
vm.onCertificateTrusted()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertThat(vm.onBackWithin()).isTrue()
|
||||||
|
|
||||||
|
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList())
|
||||||
|
vm.enterAddress("https://b.example.com")
|
||||||
|
vm.onAddressSubmitted()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertThat(gateway.discoveries.last().trust).isEqualTo(ServerTrust.DEFAULT)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `declining a certificate is offered without the back gesture`() = runTest(dispatcher) {
|
||||||
|
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.UntrustedCertificate(certificate)
|
||||||
|
val vm = viewModel()
|
||||||
|
vm.enterAddress("https://nas.home.arpa")
|
||||||
|
vm.onAddressSubmitted()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertThat(vm.onBackWithin()).isTrue()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertThat((vm.state.value.step as AddAccountStep.EnterAddress).input).isEqualTo("https://nas.home.arpa")
|
||||||
|
assertThat(gateway.discoveries).hasSize(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an email whose domain will not set up TLS asks for the server`() = runTest(dispatcher) {
|
||||||
|
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.Failed(CalDavDiscovery.Outcome.Cause.TLS, "tls")
|
||||||
|
val vm = viewModel()
|
||||||
|
vm.onProviderChosen(ProviderChoice.Service(CalDavProvider.MAILBOX_ORG))
|
||||||
|
vm.onAddressChanged("me@mailbox.org")
|
||||||
|
vm.onAddressSubmitted()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val step = vm.state.value.step as AddAccountStep.EnterAddress
|
||||||
|
assertThat(step.manualServer).isTrue()
|
||||||
|
assertThat(step.error).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `signing in again starts from what the account trusts`() = runTest(dispatcher) {
|
||||||
|
val stored = ServerTrust(pinnedCertificates = setOf("cd".repeat(32)), allowCleartext = true)
|
||||||
|
creator.storedTrust[9] = stored
|
||||||
|
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList())
|
||||||
|
val vm = viewModel()
|
||||||
|
vm.startReauthentication(9, "http://nas.local/dav/principals/me/", "me")
|
||||||
|
advanceUntilIdle()
|
||||||
|
vm.onAddressSubmitted()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertThat(gateway.discoveries.single().trust).isEqualTo(stored)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a stored trust that loads late is not applied to an edited address`() = runTest(dispatcher) {
|
||||||
|
creator.storedTrust[9] = ServerTrust(allowCleartext = true)
|
||||||
|
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.Failed(
|
||||||
|
CalDavDiscovery.Outcome.Cause.INSECURE,
|
||||||
|
"http",
|
||||||
|
)
|
||||||
|
val vm = viewModel()
|
||||||
|
vm.startReauthentication(9, "http://nas.local/dav/principals/me/", "me")
|
||||||
|
// Submitted before the stored trust has been read.
|
||||||
|
vm.onAddressChanged("http://other.local")
|
||||||
|
vm.onAddressSubmitted()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertThat(gateway.discoveries.single().trust).isEqualTo(ServerTrust.DEFAULT)
|
||||||
|
assertThat(vm.state.value.step).isEqualTo(AddAccountStep.ConfirmCleartext("http://other.local"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a renewed certificate replaces the one the account trusted`() = runTest(dispatcher) {
|
||||||
|
creator.storedTrust[9] = ServerTrust(pinnedCertificates = setOf("cd".repeat(32)))
|
||||||
|
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.UntrustedCertificate(certificate)
|
||||||
|
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList())
|
||||||
|
gateway.loginFlow = null
|
||||||
|
val vm = viewModel()
|
||||||
|
vm.startReauthentication(9, "https://nas.home.arpa/dav/principals/me/", "me")
|
||||||
|
advanceUntilIdle()
|
||||||
|
vm.onAddressSubmitted()
|
||||||
|
advanceUntilIdle()
|
||||||
|
vm.onCertificateTrusted()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertThat(gateway.discoveries.last().trust.pinnedCertificates)
|
||||||
|
.containsExactly(certificate.fingerprint)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `starting over forgets what was trusted`() = runTest(dispatcher) {
|
||||||
|
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.UntrustedCertificate(certificate)
|
||||||
|
val vm = viewModel()
|
||||||
|
vm.enterAddress("https://nas.home.arpa")
|
||||||
|
vm.onAddressSubmitted()
|
||||||
|
advanceUntilIdle()
|
||||||
|
vm.onCertificateTrusted()
|
||||||
|
advanceUntilIdle()
|
||||||
|
vm.onStartOver()
|
||||||
|
|
||||||
|
vm.enterAddress("https://nas.home.arpa")
|
||||||
|
vm.onAddressSubmitted()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertThat(gateway.discoveries.last().trust).isEqualTo(ServerTrust.DEFAULT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A ViewModel driven the way the screen drives it, up to the list step:
|
* A ViewModel driven the way the screen drives it, up to the list step:
|
||||||
* address → credentials → discovery → lists. Credentials are captured on
|
* address → credentials → discovery → lists. Credentials are captured on
|
||||||
* submit, so going straight from the address to a `Found` leaves the
|
* submit, so going straight from the address to a `Found` leaves the
|
||||||
* username and password empty.
|
* username and password empty.
|
||||||
*/
|
*/
|
||||||
private fun kotlinx.coroutines.test.TestScope.signedIn(): AddAccountViewModel {
|
private fun kotlinx.coroutines.test.TestScope.signedIn(
|
||||||
|
address: String = "https://cloud.example.com/",
|
||||||
|
): AddAccountViewModel {
|
||||||
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList())
|
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList())
|
||||||
gateway.loginFlow = null
|
gateway.loginFlow = null
|
||||||
|
|
||||||
val vm = viewModel()
|
val vm = viewModel()
|
||||||
vm.enterAddress("https://cloud.example.com/")
|
vm.enterAddress(address)
|
||||||
vm.onAddressSubmitted()
|
vm.onAddressSubmitted()
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
@@ -1465,7 +1708,11 @@ class AddAccountViewModelTest {
|
|||||||
)
|
)
|
||||||
|
|
||||||
private class FakeGateway : CalDavGateway {
|
private class FakeGateway : CalDavGateway {
|
||||||
data class Call(val target: String, val credentials: CalDavGateway.Credentials?)
|
data class Call(
|
||||||
|
val target: String,
|
||||||
|
val credentials: CalDavGateway.Credentials?,
|
||||||
|
val trust: ServerTrust = ServerTrust.DEFAULT,
|
||||||
|
)
|
||||||
|
|
||||||
val discoveries = mutableListOf<Call>()
|
val discoveries = mutableListOf<Call>()
|
||||||
val discoveryOutcomes = ArrayDeque<CalDavDiscovery.Outcome>()
|
val discoveryOutcomes = ArrayDeque<CalDavDiscovery.Outcome>()
|
||||||
@@ -1475,8 +1722,9 @@ class AddAccountViewModelTest {
|
|||||||
override suspend fun discover(
|
override suspend fun discover(
|
||||||
target: String,
|
target: String,
|
||||||
credentials: CalDavGateway.Credentials?,
|
credentials: CalDavGateway.Credentials?,
|
||||||
|
trust: ServerTrust,
|
||||||
): CalDavDiscovery.Outcome {
|
): CalDavDiscovery.Outcome {
|
||||||
discoveries += Call(target, credentials)
|
discoveries += Call(target, credentials, trust)
|
||||||
return discoveryOutcomes.removeFirstOrNull()
|
return discoveryOutcomes.removeFirstOrNull()
|
||||||
?: CalDavDiscovery.Outcome.Failed(
|
?: CalDavDiscovery.Outcome.Failed(
|
||||||
CalDavDiscovery.Outcome.Cause.SERVER_ERROR,
|
CalDavDiscovery.Outcome.Cause.SERVER_ERROR,
|
||||||
@@ -1486,17 +1734,18 @@ class AddAccountViewModelTest {
|
|||||||
|
|
||||||
var loginFlowsStarted = 0
|
var loginFlowsStarted = 0
|
||||||
|
|
||||||
override suspend fun startLoginFlow(server: HttpUrl): NextcloudLoginFlow.Flow? {
|
override suspend fun startLoginFlow(server: HttpUrl, trust: ServerTrust): NextcloudLoginFlow.Flow? {
|
||||||
loginFlowsStarted++
|
loginFlowsStarted++
|
||||||
return loginFlow
|
return loginFlow
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun pollLoginFlow(flow: NextcloudLoginFlow.Flow) =
|
override suspend fun pollLoginFlow(flow: NextcloudLoginFlow.Flow, trust: ServerTrust) =
|
||||||
pollResults.removeFirstOrNull() ?: NextcloudLoginFlow.PollResult.Pending
|
pollResults.removeFirstOrNull() ?: NextcloudLoginFlow.PollResult.Pending
|
||||||
|
|
||||||
/** Revocation is best effort and the flow never depends on it. */
|
/** Revocation is best effort and the flow never depends on it. */
|
||||||
override suspend fun revokeAppPassword(
|
override suspend fun revokeAppPassword(
|
||||||
credentials: CalDavGateway.Credentials,
|
credentials: CalDavGateway.Credentials,
|
||||||
|
trust: ServerTrust,
|
||||||
): Boolean = true
|
): Boolean = true
|
||||||
|
|
||||||
val revoked = mutableListOf<CalDavGateway.Credentials>()
|
val revoked = mutableListOf<CalDavGateway.Credentials>()
|
||||||
@@ -1504,6 +1753,7 @@ class AddAccountViewModelTest {
|
|||||||
|
|
||||||
override suspend fun revokeIssuedAppPassword(
|
override suspend fun revokeIssuedAppPassword(
|
||||||
credentials: CalDavGateway.Credentials,
|
credentials: CalDavGateway.Credentials,
|
||||||
|
trust: ServerTrust,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
revoked += credentials
|
revoked += credentials
|
||||||
return revokeSucceeds
|
return revokeSucceeds
|
||||||
@@ -1515,7 +1765,7 @@ class AddAccountViewModelTest {
|
|||||||
val log = mutableListOf<String>()
|
val log = mutableListOf<String>()
|
||||||
var remembered: NextcloudLoginFlow.Flow? = null
|
var remembered: NextcloudLoginFlow.Flow? = null
|
||||||
|
|
||||||
override suspend fun remember(flow: NextcloudLoginFlow.Flow) {
|
override suspend fun remember(flow: NextcloudLoginFlow.Flow, trust: ServerTrust) {
|
||||||
remembered = flow
|
remembered = flow
|
||||||
log += "remember"
|
log += "remember"
|
||||||
}
|
}
|
||||||
@@ -1531,6 +1781,11 @@ class AddAccountViewModelTest {
|
|||||||
val reauthenticated = mutableListOf<Long?>()
|
val reauthenticated = mutableListOf<Long?>()
|
||||||
var thrown: Throwable? = null
|
var thrown: Throwable? = null
|
||||||
var outcome: AccountRepository.Outcome? = null
|
var outcome: AccountRepository.Outcome? = null
|
||||||
|
val savedTrust = mutableListOf<ServerTrust>()
|
||||||
|
val storedTrust = mutableMapOf<Long, ServerTrust>()
|
||||||
|
|
||||||
|
override suspend fun trustFor(accountId: Long): ServerTrust =
|
||||||
|
storedTrust[accountId] ?: ServerTrust.DEFAULT
|
||||||
|
|
||||||
override suspend fun create(
|
override suspend fun create(
|
||||||
displayName: String,
|
displayName: String,
|
||||||
@@ -1539,8 +1794,10 @@ class AddAccountViewModelTest {
|
|||||||
found: CalDavDiscovery.Outcome.Found,
|
found: CalDavDiscovery.Outcome.Found,
|
||||||
selected: Set<TaskCollection>,
|
selected: Set<TaskCollection>,
|
||||||
reauthenticating: Long?,
|
reauthenticating: Long?,
|
||||||
|
trust: ServerTrust,
|
||||||
): AccountRepository.Outcome {
|
): AccountRepository.Outcome {
|
||||||
reauthenticated += reauthenticating
|
reauthenticated += reauthenticating
|
||||||
|
savedTrust += trust
|
||||||
thrown?.let { throw it }
|
thrown?.let { throw it }
|
||||||
outcome?.let { return it }
|
outcome?.let { return it }
|
||||||
created += displayName
|
created += displayName
|
||||||
|
|||||||
@@ -32,5 +32,6 @@ dependencies {
|
|||||||
testImplementation(libs.xpp3)
|
testImplementation(libs.xpp3)
|
||||||
testImplementation(libs.junit4)
|
testImplementation(libs.junit4)
|
||||||
testImplementation(libs.okhttp.mockwebserver)
|
testImplementation(libs.okhttp.mockwebserver)
|
||||||
|
testImplementation(libs.okhttp.tls)
|
||||||
testImplementation(libs.truth)
|
testImplementation(libs.truth)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import okhttp3.HttpUrl
|
|||||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import javax.net.ssl.SSLException
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RFC 6764 discovery: from what the user typed to the list of task collections.
|
* RFC 6764 discovery: from what the user typed to the list of task collections.
|
||||||
@@ -44,6 +45,8 @@ class CalDavDiscovery(
|
|||||||
val url: HttpUrl,
|
val url: HttpUrl,
|
||||||
val reason: String,
|
val reason: String,
|
||||||
val needsAuthentication: Boolean,
|
val needsAuthentication: Boolean,
|
||||||
|
/** Set when the home set's host presented a certificate the user could trust. */
|
||||||
|
val certificate: ServerCertificate? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed interface Outcome {
|
sealed interface Outcome {
|
||||||
@@ -85,6 +88,12 @@ class CalDavDiscovery(
|
|||||||
|
|
||||||
data class Failed(val cause: Cause, val detail: String) : Outcome
|
data class Failed(val cause: Cause, val detail: String) : Outcome
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The server's certificate is not trusted by the platform. Not a
|
||||||
|
* failure either — the user may trust it and retry.
|
||||||
|
*/
|
||||||
|
data class UntrustedCertificate(val certificate: ServerCertificate) : Outcome
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Why discovery ended, in a form the UI can translate.
|
* Why discovery ended, in a form the UI can translate.
|
||||||
*
|
*
|
||||||
@@ -105,9 +114,12 @@ class CalDavDiscovery(
|
|||||||
/** Speaks WebDAV but not CalDAV — a file-sharing endpoint, say. */
|
/** Speaks WebDAV but not CalDAV — a file-sharing endpoint, say. */
|
||||||
NO_CALENDAR_SUPPORT,
|
NO_CALENDAR_SUPPORT,
|
||||||
|
|
||||||
/** Nothing answered: DNS, connection refused, TLS, timeout. */
|
/** Nothing answered: DNS, connection refused, timeout. */
|
||||||
UNREACHABLE,
|
UNREACHABLE,
|
||||||
|
|
||||||
|
/** A secure connection could not be set up, for a reason the user cannot waive. */
|
||||||
|
TLS,
|
||||||
|
|
||||||
/** The address, or where it redirects, is plain HTTP. */
|
/** The address, or where it redirects, is plain HTTP. */
|
||||||
INSECURE,
|
INSECURE,
|
||||||
|
|
||||||
@@ -128,12 +140,13 @@ class CalDavDiscovery(
|
|||||||
* means exactly "this is not a DAV server". Surfacing it as "HTTP 405 Method
|
* means exactly "this is not a DAV server". Surfacing it as "HTTP 405 Method
|
||||||
* Not Allowed" hands the user a status code where they needed a sentence.
|
* Not Allowed" hands the user a status code where they needed a sentence.
|
||||||
*/
|
*/
|
||||||
private fun causeOf(error: Throwable): Outcome.Cause = when {
|
internal fun causeOf(error: Throwable): Outcome.Cause = when {
|
||||||
error is HttpException -> when (error.code) {
|
error is HttpException -> when (error.code) {
|
||||||
METHOD_NOT_ALLOWED, NOT_IMPLEMENTED, NOT_FOUND -> Outcome.Cause.NOT_A_DAV_SERVER
|
METHOD_NOT_ALLOWED, NOT_IMPLEMENTED, NOT_FOUND -> Outcome.Cause.NOT_A_DAV_SERVER
|
||||||
else -> Outcome.Cause.SERVER_ERROR
|
else -> Outcome.Cause.SERVER_ERROR
|
||||||
}
|
}
|
||||||
// Everything that never got an answer: DNS, refused, TLS, timeout.
|
error is SSLException -> Outcome.Cause.TLS
|
||||||
|
// Everything that never got an answer: DNS, refused, timeout.
|
||||||
error is IOException -> Outcome.Cause.UNREACHABLE
|
error is IOException -> Outcome.Cause.UNREACHABLE
|
||||||
else -> Outcome.Cause.SERVER_ERROR
|
else -> Outcome.Cause.SERVER_ERROR
|
||||||
}
|
}
|
||||||
@@ -144,6 +157,11 @@ class CalDavDiscovery(
|
|||||||
* A 401 stops the walk immediately: it means we found a DAV server and simply
|
* A 401 stops the walk immediately: it means we found a DAV server and simply
|
||||||
* have no credentials for it, and continuing down the ladder would replace a
|
* have no credentials for it, and continuing down the ladder would replace a
|
||||||
* precise "sign in" with a vague "nothing found".
|
* precise "sign in" with a vague "nothing found".
|
||||||
|
*
|
||||||
|
* An untrusted certificate does not: a lower-priority SRV target may answer
|
||||||
|
* with one the phone trusts, so it is only offered once the ladder is spent.
|
||||||
|
* On a guessed host — the email's own domain, with no SRV record — it is not
|
||||||
|
* offered at all, since that host is most likely the domain's website.
|
||||||
*/
|
*/
|
||||||
fun discover(input: String): Outcome {
|
fun discover(input: String): Outcome {
|
||||||
ServiceDiscovery.asBaseUrl(input)?.let { typed ->
|
ServiceDiscovery.asBaseUrl(input)?.let { typed ->
|
||||||
@@ -161,13 +179,31 @@ class CalDavDiscovery(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var lastFailure: Outcome = Outcome.Failed(Outcome.Cause.UNREACHABLE, "no candidate answered")
|
var lastFailure: Outcome = Outcome.Failed(Outcome.Cause.UNREACHABLE, "no candidate answered")
|
||||||
|
var untrusted: Outcome.UntrustedCertificate? = null
|
||||||
|
// Same host, same certificate: the remaining rungs on it would only fail again.
|
||||||
|
val refusedOrigins = mutableSetOf<String>()
|
||||||
for (candidate in candidates) {
|
for (candidate in candidates) {
|
||||||
|
if (candidate.url.originKey() in refusedOrigins) continue
|
||||||
when (val outcome = probe(candidate.url)) {
|
when (val outcome = probe(candidate.url)) {
|
||||||
is Outcome.Found, is Outcome.NeedsAuthentication, Outcome.Unauthenticated -> return outcome
|
is Outcome.Found,
|
||||||
|
is Outcome.NeedsAuthentication,
|
||||||
|
Outcome.Unauthenticated,
|
||||||
|
-> return outcome
|
||||||
|
is Outcome.UntrustedCertificate -> {
|
||||||
|
refusedOrigins += candidate.url.originKey()
|
||||||
|
if (candidate.guessed) {
|
||||||
|
lastFailure = Outcome.Failed(
|
||||||
|
Outcome.Cause.TLS,
|
||||||
|
"untrusted certificate on guessed host ${candidate.url.host}",
|
||||||
|
)
|
||||||
|
} else if (untrusted == null) {
|
||||||
|
untrusted = outcome
|
||||||
|
}
|
||||||
|
}
|
||||||
else -> lastFailure = outcome
|
else -> lastFailure = outcome
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return lastFailure
|
return untrusted ?: lastFailure
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun probe(base: HttpUrl): Outcome {
|
internal fun probe(base: HttpUrl): Outcome {
|
||||||
@@ -195,7 +231,7 @@ class CalDavDiscovery(
|
|||||||
// 401 is not a failure — iCloud and Zoho answer it from
|
// 401 is not a failure — iCloud and Zoho answer it from
|
||||||
// /.well-known/caldav, which *is* the DAV root, and it is RFC-legal.
|
// /.well-known/caldav, which *is* the DAV root, and it is RFC-legal.
|
||||||
if (isUnauthorized(error)) return Outcome.NeedsAuthentication(listOf(base.host))
|
if (isUnauthorized(error)) return Outcome.NeedsAuthentication(listOf(base.host))
|
||||||
return Outcome.Failed(causeOf(error), error.message ?: error.toString())
|
return failure(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ⚠️ RFC 5397 §3: a 200 carrying <D:unauthenticated/> means the
|
// ⚠️ RFC 5397 §3: a 200 carrying <D:unauthenticated/> means the
|
||||||
@@ -258,7 +294,7 @@ class CalDavDiscovery(
|
|||||||
}
|
}
|
||||||
homeSetResult.exceptionOrNull()?.let { error ->
|
homeSetResult.exceptionOrNull()?.let { error ->
|
||||||
if (isUnauthorized(error)) return Outcome.NeedsAuthentication(listOf(principal.host))
|
if (isUnauthorized(error)) return Outcome.NeedsAuthentication(listOf(principal.host))
|
||||||
return Outcome.Failed(causeOf(error), error.message ?: error.toString())
|
return failure(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (homeSets.isEmpty()) {
|
if (homeSets.isEmpty()) {
|
||||||
@@ -299,6 +335,7 @@ class CalDavDiscovery(
|
|||||||
url = homeSet,
|
url = homeSet,
|
||||||
reason = it.message ?: it.toString(),
|
reason = it.message ?: it.toString(),
|
||||||
needsAuthentication = isUnauthorized(it),
|
needsAuthentication = isUnauthorized(it),
|
||||||
|
certificate = untrustedCertificateIn(it)?.let(ServerCertificate::of),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -309,8 +346,11 @@ class CalDavDiscovery(
|
|||||||
// lists" for what is actually an error.
|
// lists" for what is actually an error.
|
||||||
if (!anySucceeded) {
|
if (!anySucceeded) {
|
||||||
val needAuth = failures.filter { it.needsAuthentication }
|
val needAuth = failures.filter { it.needsAuthentication }
|
||||||
|
val certificate = failures.firstNotNullOfOrNull { it.certificate }
|
||||||
return if (needAuth.isNotEmpty() && needAuth.size == failures.size) {
|
return if (needAuth.isNotEmpty() && needAuth.size == failures.size) {
|
||||||
Outcome.NeedsAuthentication(needAuth.map { it.url.host }.distinct())
|
Outcome.NeedsAuthentication(needAuth.map { it.url.host }.distinct())
|
||||||
|
} else if (certificate != null) {
|
||||||
|
Outcome.UntrustedCertificate(certificate)
|
||||||
} else {
|
} else {
|
||||||
Outcome.Failed(
|
Outcome.Failed(
|
||||||
Outcome.Cause.NO_CALENDARS,
|
Outcome.Cause.NO_CALENDARS,
|
||||||
@@ -329,6 +369,12 @@ class CalDavDiscovery(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun failure(error: Throwable): Outcome =
|
||||||
|
untrustedCertificateIn(error)?.let { Outcome.UntrustedCertificate(ServerCertificate.of(it)) }
|
||||||
|
?: Outcome.Failed(causeOf(error), error.message ?: error.toString())
|
||||||
|
|
||||||
|
private fun HttpUrl.originKey() = "$scheme://$host:$port"
|
||||||
|
|
||||||
private fun isUnauthorized(error: Throwable): Boolean =
|
private fun isUnauthorized(error: Throwable): Boolean =
|
||||||
error is at.bitfire.dav4jvm.exception.UnauthorizedException
|
error is at.bitfire.dav4jvm.exception.UnauthorizedException
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.jeanlucmakiola.caldav
|
package de.jeanlucmakiola.caldav
|
||||||
|
|
||||||
import at.bitfire.dav4jvm.BasicDigestAuthHandler
|
import at.bitfire.dav4jvm.BasicDigestAuthHandler
|
||||||
|
import okhttp3.ConnectionSpec
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
@@ -9,6 +10,8 @@ import okhttp3.ResponseBody.Companion.asResponseBody
|
|||||||
import okio.GzipSource
|
import okio.GzipSource
|
||||||
import okio.buffer
|
import okio.buffer
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
import javax.net.ssl.SSLContext
|
||||||
|
import javax.net.ssl.SSLSocketFactory
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The HTTP clients the CalDAV layer talks through.
|
* The HTTP clients the CalDAV layer talks through.
|
||||||
@@ -45,7 +48,8 @@ object CalDavHttp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Discovery before we have credentials, and the Nextcloud login flow. */
|
/** Discovery before we have credentials, and the Nextcloud login flow. */
|
||||||
fun anonymous(userAgent: String): OkHttpClient = base(userAgent).build()
|
fun anonymous(userAgent: String, trust: ServerTrust = ServerTrust.DEFAULT): OkHttpClient =
|
||||||
|
base(userAgent, trust).build()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authenticated against [origin]'s registrable domain.
|
* Authenticated against [origin]'s registrable domain.
|
||||||
@@ -75,6 +79,7 @@ object CalDavHttp {
|
|||||||
username: String,
|
username: String,
|
||||||
password: String,
|
password: String,
|
||||||
origin: HttpUrl,
|
origin: HttpUrl,
|
||||||
|
trust: ServerTrust = ServerTrust.DEFAULT,
|
||||||
): OkHttpClient {
|
): OkHttpClient {
|
||||||
val handler = BasicDigestAuthHandler(
|
val handler = BasicDigestAuthHandler(
|
||||||
// ⚠️ The **registrable** domain, not the host, and it must be
|
// ⚠️ The **registrable** domain, not the host, and it must be
|
||||||
@@ -97,16 +102,34 @@ object CalDavHttp {
|
|||||||
domain = origin.topPrivateDomain() ?: origin.host,
|
domain = origin.topPrivateDomain() ?: origin.host,
|
||||||
username = username,
|
username = username,
|
||||||
password = password,
|
password = password,
|
||||||
// Never over cleartext, challenged or not.
|
// Never over cleartext, challenged or not — unless the user opted
|
||||||
insecureBasic = false,
|
// this account into plain http, where nothing else could work.
|
||||||
|
insecureBasic = trust.allowCleartext,
|
||||||
)
|
)
|
||||||
return base(userAgent)
|
return base(userAgent, trust)
|
||||||
.authenticator(handler)
|
.authenticator(handler)
|
||||||
.addNetworkInterceptor(handler)
|
.addNetworkInterceptor(handler)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun base(userAgent: String) = shared.newBuilder()
|
/**
|
||||||
|
* Every client verifies TLS through [PinningTrustManager], and speaks plain
|
||||||
|
* http only when [trust] says the user opted in: without `CLEARTEXT` in the
|
||||||
|
* connection specs OkHttp itself refuses an `http://` URL.
|
||||||
|
*/
|
||||||
|
private fun base(userAgent: String, trust: ServerTrust) = shared.newBuilder()
|
||||||
|
.apply {
|
||||||
|
val tls = tlsFor(trust)
|
||||||
|
sslSocketFactory(tls.socketFactory, tls.trustManager)
|
||||||
|
hostnameVerifier(tls.hostnameVerifier)
|
||||||
|
connectionSpecs(
|
||||||
|
if (trust.allowCleartext) {
|
||||||
|
listOf(ConnectionSpec.MODERN_TLS, ConnectionSpec.CLEARTEXT)
|
||||||
|
} else {
|
||||||
|
listOf(ConnectionSpec.MODERN_TLS)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
.addInterceptor(calendarHeaders)
|
.addInterceptor(calendarHeaders)
|
||||||
.addInterceptor { chain ->
|
.addInterceptor { chain ->
|
||||||
chain.proceed(
|
chain.proceed(
|
||||||
@@ -114,6 +137,32 @@ object CalDavHttp {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private class Tls(pins: Set<String>) {
|
||||||
|
private val trust = ServerTrust(pinnedCertificates = pins)
|
||||||
|
val trustManager = PinningTrustManager(trust)
|
||||||
|
val socketFactory: SSLSocketFactory = SSLContext.getInstance("TLS")
|
||||||
|
.apply { init(null, arrayOf(trustManager), null) }
|
||||||
|
.socketFactory
|
||||||
|
val hostnameVerifier = PinningHostnameVerifier(trust)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One socket factory per distinct pin set, because OkHttp only pools a
|
||||||
|
* connection between clients whose factory and verifier are the same
|
||||||
|
* instances — a fresh one per call would reopen TLS for every client.
|
||||||
|
* Keyed on the pins alone (cleartext has no bearing on TLS), and bounded,
|
||||||
|
* since every abandoned setup attempt leaves a pin set behind.
|
||||||
|
*/
|
||||||
|
private val tlsCache = object : LinkedHashMap<Set<String>, Tls>(TLS_CACHE_SIZE, 0.75f, true) {
|
||||||
|
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Set<String>, Tls>) = size > TLS_CACHE_SIZE
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tlsFor(trust: ServerTrust): Tls = synchronized(tlsCache) {
|
||||||
|
tlsCache.getOrPut(trust.pinnedCertificates) { Tls(trust.pinnedCertificates) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val TLS_CACHE_SIZE = 16
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The two headers that decide whether ETags are usable and whether our bytes
|
* The two headers that decide whether ETags are usable and whether our bytes
|
||||||
* survive the server.
|
* survive the server.
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package de.jeanlucmakiola.caldav
|
||||||
|
|
||||||
|
import okhttp3.internal.tls.OkHostnameVerifier
|
||||||
|
import java.net.Socket
|
||||||
|
import java.security.KeyStore
|
||||||
|
import java.security.cert.CertificateException
|
||||||
|
import java.security.cert.X509Certificate
|
||||||
|
import javax.net.ssl.HostnameVerifier
|
||||||
|
import javax.net.ssl.SSLEngine
|
||||||
|
import javax.net.ssl.SSLSession
|
||||||
|
import javax.net.ssl.TrustManagerFactory
|
||||||
|
import javax.net.ssl.X509ExtendedTrustManager
|
||||||
|
import javax.net.ssl.X509TrustManager
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The platform's trust decision, with the user's pinned certificates as the
|
||||||
|
* only exception to it.
|
||||||
|
*
|
||||||
|
* On Android the platform manager is the network security config's, so system
|
||||||
|
* and user-installed CAs keep working exactly as before. A certificate it
|
||||||
|
* rejects is accepted only if its leaf is pinned in [trust]; otherwise the
|
||||||
|
* failure carries the certificate so the user can be asked about it.
|
||||||
|
*/
|
||||||
|
internal class PinningTrustManager(
|
||||||
|
private val trust: ServerTrust,
|
||||||
|
private val platform: X509TrustManager = platformTrustManager,
|
||||||
|
) : X509ExtendedTrustManager() {
|
||||||
|
|
||||||
|
override fun checkServerTrusted(chain: Array<out X509Certificate>, authType: String) =
|
||||||
|
pinnedOr(chain) { platform.checkServerTrusted(chain, authType) }
|
||||||
|
|
||||||
|
override fun checkServerTrusted(chain: Array<out X509Certificate>, authType: String, socket: Socket?) =
|
||||||
|
pinnedOr(chain) {
|
||||||
|
if (platform is X509ExtendedTrustManager) {
|
||||||
|
platform.checkServerTrusted(chain, authType, socket)
|
||||||
|
} else {
|
||||||
|
platform.checkServerTrusted(chain, authType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun checkServerTrusted(chain: Array<out X509Certificate>, authType: String, engine: SSLEngine?) =
|
||||||
|
pinnedOr(chain) {
|
||||||
|
if (platform is X509ExtendedTrustManager) {
|
||||||
|
platform.checkServerTrusted(chain, authType, engine)
|
||||||
|
} else {
|
||||||
|
platform.checkServerTrusted(chain, authType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun checkClientTrusted(chain: Array<out X509Certificate>, authType: String) =
|
||||||
|
platform.checkClientTrusted(chain, authType)
|
||||||
|
|
||||||
|
override fun checkClientTrusted(chain: Array<out X509Certificate>, authType: String, socket: Socket?) =
|
||||||
|
if (platform is X509ExtendedTrustManager) {
|
||||||
|
platform.checkClientTrusted(chain, authType, socket)
|
||||||
|
} else {
|
||||||
|
platform.checkClientTrusted(chain, authType)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun checkClientTrusted(chain: Array<out X509Certificate>, authType: String, engine: SSLEngine?) =
|
||||||
|
if (platform is X509ExtendedTrustManager) {
|
||||||
|
platform.checkClientTrusted(chain, authType, engine)
|
||||||
|
} else {
|
||||||
|
platform.checkClientTrusted(chain, authType)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAcceptedIssuers(): Array<X509Certificate> = platform.acceptedIssuers
|
||||||
|
|
||||||
|
private inline fun pinnedOr(chain: Array<out X509Certificate>, check: () -> Unit) {
|
||||||
|
try {
|
||||||
|
check()
|
||||||
|
} catch (error: CertificateException) {
|
||||||
|
val leaf = chain.firstOrNull() ?: throw error
|
||||||
|
if (trust.trusts(leaf)) return
|
||||||
|
throw UntrustedCertificateException(leaf, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** The platform default, honouring Android's network security config. */
|
||||||
|
val platformTrustManager: X509TrustManager by lazy {
|
||||||
|
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||||
|
factory.init(null as KeyStore?)
|
||||||
|
factory.trustManagers.filterIsInstance<X509TrustManager>().first()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OkHttp's hostname check, waived for a pinned leaf: a self-signed certificate
|
||||||
|
* is routinely issued for a name other than the one the phone dials.
|
||||||
|
*/
|
||||||
|
internal class PinningHostnameVerifier(private val trust: ServerTrust) : HostnameVerifier {
|
||||||
|
|
||||||
|
override fun verify(host: String, session: SSLSession): Boolean {
|
||||||
|
if (OkHostnameVerifier.verify(host, session)) return true
|
||||||
|
val leaf = runCatching { session.peerCertificates.firstOrNull() as? X509Certificate }
|
||||||
|
.getOrNull() ?: return false
|
||||||
|
if (trust.trusts(leaf)) return true
|
||||||
|
throw UntrustedHostException(leaf, host)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package de.jeanlucmakiola.caldav
|
||||||
|
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.security.cert.CertificateException
|
||||||
|
import java.security.cert.X509Certificate
|
||||||
|
import javax.net.ssl.SSLPeerUnverifiedException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the user has agreed to accept from one account's server beyond the
|
||||||
|
* platform's defaults.
|
||||||
|
*
|
||||||
|
* @property pinnedCertificates SHA-256 fingerprints of leaf certificates the
|
||||||
|
* user trusted on first use, lowercase hex without separators.
|
||||||
|
* @property allowCleartext whether plain `http://` may be spoken at all.
|
||||||
|
*/
|
||||||
|
data class ServerTrust(
|
||||||
|
val pinnedCertificates: Set<String> = emptySet(),
|
||||||
|
val allowCleartext: Boolean = false,
|
||||||
|
) {
|
||||||
|
fun withCertificate(fingerprint: String): ServerTrust =
|
||||||
|
copy(pinnedCertificates = pinnedCertificates + fingerprint.lowercase())
|
||||||
|
|
||||||
|
fun trusts(certificate: X509Certificate): Boolean =
|
||||||
|
pinnedCertificates.isNotEmpty() && fingerprintOf(certificate) in pinnedCertificates
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val DEFAULT = ServerTrust()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A certificate the platform does not trust, as the user is shown it. */
|
||||||
|
data class ServerCertificate(
|
||||||
|
/** SHA-256 of the DER encoding, lowercase hex without separators. */
|
||||||
|
val fingerprint: String,
|
||||||
|
val subject: String,
|
||||||
|
val issuer: String,
|
||||||
|
val notAfterMillis: Long,
|
||||||
|
) {
|
||||||
|
/** The fingerprint as `AB:CD:…`, the way server tooling prints it. */
|
||||||
|
val displayFingerprint: String get() = formatFingerprint(fingerprint)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun of(certificate: X509Certificate) = ServerCertificate(
|
||||||
|
fingerprint = fingerprintOf(certificate),
|
||||||
|
subject = certificate.subjectX500Principal.name,
|
||||||
|
issuer = certificate.issuerX500Principal.name,
|
||||||
|
notAfterMillis = certificate.notAfter.time,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun formatFingerprint(fingerprint: String): String =
|
||||||
|
fingerprint.uppercase().chunked(2).joinToString(":")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SHA-256 of [certificate]'s DER encoding, lowercase hex. */
|
||||||
|
fun fingerprintOf(certificate: X509Certificate): String =
|
||||||
|
MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(certificate.encoded)
|
||||||
|
.joinToString("") { "%02x".format(it) }
|
||||||
|
|
||||||
|
/** The platform rejected [certificate] and the user has not pinned it. */
|
||||||
|
class UntrustedCertificateException(
|
||||||
|
val certificate: X509Certificate,
|
||||||
|
cause: Throwable?,
|
||||||
|
) : CertificateException("certificate ${fingerprintOf(certificate)} is not trusted", cause)
|
||||||
|
|
||||||
|
/** [certificate] does not name [host], and the user has not pinned it. */
|
||||||
|
class UntrustedHostException(
|
||||||
|
val certificate: X509Certificate,
|
||||||
|
host: String,
|
||||||
|
) : SSLPeerUnverifiedException("certificate ${fingerprintOf(certificate)} does not name $host")
|
||||||
|
|
||||||
|
/** The certificate the user could choose to trust, if [error] was caused by one. */
|
||||||
|
fun untrustedCertificateIn(error: Throwable): X509Certificate? {
|
||||||
|
var current: Throwable? = error
|
||||||
|
val seen = mutableSetOf<Throwable>()
|
||||||
|
while (current != null && seen.add(current)) {
|
||||||
|
when (current) {
|
||||||
|
is UntrustedCertificateException -> return current.certificate
|
||||||
|
is UntrustedHostException -> return current.certificate
|
||||||
|
}
|
||||||
|
current = current.cause
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -51,8 +51,14 @@ object ServiceDiscovery {
|
|||||||
private const val SRV_SERVICE = "_caldavs._tcp"
|
private const val SRV_SERVICE = "_caldavs._tcp"
|
||||||
private const val WELL_KNOWN = "/.well-known/caldav"
|
private const val WELL_KNOWN = "/.well-known/caldav"
|
||||||
|
|
||||||
/** A URL to probe, and where it came from — the "why" a failure report needs. */
|
/**
|
||||||
data class Candidate(val url: HttpUrl, val origin: String)
|
* A URL to probe, and where it came from — the "why" a failure report needs.
|
||||||
|
*
|
||||||
|
* @property guessed the host is the email's domain taken on spec, with no
|
||||||
|
* SRV record naming it. Usually that is the domain's website, so its
|
||||||
|
* certificate is not one worth asking the user to trust.
|
||||||
|
*/
|
||||||
|
data class Candidate(val url: HttpUrl, val origin: String, val guessed: Boolean = false)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hosts whose SRV record leads somewhere that is not a DAV server.
|
* Hosts whose SRV record leads somewhere that is not a DAV server.
|
||||||
@@ -149,16 +155,15 @@ object ServiceDiscovery {
|
|||||||
// explicit, which is the whole point of Posteo's 8443.
|
// explicit, which is the whole point of Posteo's 8443.
|
||||||
val port = if (record.port == 443) "" else ":${record.port}"
|
val port = if (record.port == 443) "" else ":${record.port}"
|
||||||
"https://$host$port" to "SRV $host$port"
|
"https://$host$port" to "SRV $host$port"
|
||||||
}.ifEmpty {
|
|
||||||
listOf("https://$domain" to "domain as typed")
|
|
||||||
}
|
}
|
||||||
|
val guessed = origins.isEmpty()
|
||||||
|
|
||||||
for ((origin, label) in origins) {
|
for ((origin, label) in origins.ifEmpty { listOf("https://$domain" to "domain as typed") }) {
|
||||||
if (txtPath != null) {
|
if (txtPath != null) {
|
||||||
add(candidates, origin, txtPath, "$label + TXT path=$txtPath")
|
add(candidates, origin, txtPath, "$label + TXT path=$txtPath", guessed)
|
||||||
}
|
}
|
||||||
add(candidates, origin, WELL_KNOWN, "$label + .well-known")
|
add(candidates, origin, WELL_KNOWN, "$label + .well-known", guessed)
|
||||||
add(candidates, origin, "/", "$label + root")
|
add(candidates, origin, "/", "$label + root", guessed)
|
||||||
}
|
}
|
||||||
return candidates
|
return candidates
|
||||||
}
|
}
|
||||||
@@ -181,9 +186,15 @@ object ServiceDiscovery {
|
|||||||
return url.topPrivateDomain() ?: url.host
|
return url.topPrivateDomain() ?: url.host
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun add(into: MutableList<Candidate>, origin: String, path: String, label: String) {
|
private fun add(
|
||||||
|
into: MutableList<Candidate>,
|
||||||
|
origin: String,
|
||||||
|
path: String,
|
||||||
|
label: String,
|
||||||
|
guessed: Boolean = false,
|
||||||
|
) {
|
||||||
val url = (origin.trimEnd('/') + "/" + path.trimStart('/')).toHttpUrlOrNull() ?: return
|
val url = (origin.trimEnd('/') + "/" + path.trimStart('/')).toHttpUrlOrNull() ?: return
|
||||||
if (into.none { it.url == url }) into += Candidate(url, label)
|
if (into.none { it.url == url }) into += Candidate(url, label, guessed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -504,7 +504,7 @@ END:VCALENDAR</C:calendar-data>
|
|||||||
// --------------------------------------------------------------- headers
|
// --------------------------------------------------------------- headers
|
||||||
|
|
||||||
@Test fun `writes carry Prefer strict and identity encoding`() {
|
@Test fun `writes carry Prefer strict and identity encoding`() {
|
||||||
val client = CalDavHttp.anonymous("Agendula test")
|
val client = CalDavHttp.anonymous("Agendula test", ServerTrust(allowCleartext = true))
|
||||||
val withHeaders = CalendarCollection(client, server.url("/dav/tasks/"))
|
val withHeaders = CalendarCollection(client, server.url("/dav/tasks/"))
|
||||||
server.enqueue(MockResponse().setResponseCode(201).setHeader("ETag", "\"e\""))
|
server.enqueue(MockResponse().setResponseCode(201).setHeader("ETag", "\"e\""))
|
||||||
|
|
||||||
@@ -519,7 +519,7 @@ END:VCALENDAR</C:calendar-data>
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test fun `a caller's own Prefer survives beside ours`() {
|
@Test fun `a caller's own Prefer survives beside ours`() {
|
||||||
val client = CalDavHttp.anonymous("Agendula test")
|
val client = CalDavHttp.anonymous("Agendula test", ServerTrust(allowCleartext = true))
|
||||||
server.enqueue(MockResponse().setResponseCode(200))
|
server.enqueue(MockResponse().setResponseCode(200))
|
||||||
|
|
||||||
client.newCall(
|
client.newCall(
|
||||||
@@ -536,7 +536,7 @@ END:VCALENDAR</C:calendar-data>
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test fun `a body gzipped despite identity is still readable`() {
|
@Test fun `a body gzipped despite identity is still readable`() {
|
||||||
val client = CalDavHttp.anonymous("Agendula test")
|
val client = CalDavHttp.anonymous("Agendula test", ServerTrust(allowCleartext = true))
|
||||||
val body = okio.Buffer()
|
val body = okio.Buffer()
|
||||||
okio.GzipSink(body).buffer().use { it.writeUtf8("BEGIN:VCALENDAR") }
|
okio.GzipSink(body).buffer().use { it.writeUtf8("BEGIN:VCALENDAR") }
|
||||||
server.enqueue(
|
server.enqueue(
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class CollectionAdminTest {
|
|||||||
|
|
||||||
@Before fun setUp() {
|
@Before fun setUp() {
|
||||||
server = MockWebServer().apply { start() }
|
server = MockWebServer().apply { start() }
|
||||||
admin = DavCollectionAdmin(CalDavHttp.anonymous("test"))
|
admin = DavCollectionAdmin(CalDavHttp.anonymous("test", ServerTrust(allowCleartext = true)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@After fun tearDown() = server.shutdown()
|
@After fun tearDown() = server.shutdown()
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
package de.jeanlucmakiola.caldav
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.mockwebserver.MockResponse
|
||||||
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
|
import okhttp3.tls.HandshakeCertificates
|
||||||
|
import okhttp3.tls.HeldCertificate
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.IOException
|
||||||
|
import java.net.ConnectException
|
||||||
|
import java.net.InetAddress
|
||||||
|
import java.net.UnknownServiceException
|
||||||
|
import java.security.cert.CertificateException
|
||||||
|
import javax.net.ssl.SSLContext
|
||||||
|
import javax.net.ssl.SSLHandshakeException
|
||||||
|
|
||||||
|
/** Trust on first use for self-signed servers, and the per-account cleartext opt-in. */
|
||||||
|
class ServerTrustTest {
|
||||||
|
|
||||||
|
private val server = MockWebServer()
|
||||||
|
|
||||||
|
@Before fun start() = server.start()
|
||||||
|
|
||||||
|
@After fun stop() = server.shutdown()
|
||||||
|
|
||||||
|
private fun selfSigned(host: String = server.hostName): HeldCertificate =
|
||||||
|
HeldCertificate.Builder()
|
||||||
|
.commonName(host)
|
||||||
|
.addSubjectAlternativeName(host)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private fun serveTls(certificate: HeldCertificate) {
|
||||||
|
val handshake = HandshakeCertificates.Builder().heldCertificate(certificate).build()
|
||||||
|
server.useHttps(handshake.sslSocketFactory(), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun options() = MockResponse().setResponseCode(200).setHeader("DAV", "1, 2, calendar-access")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a fingerprint is shown as colon-separated uppercase pairs`() {
|
||||||
|
assertThat(ServerCertificate.formatFingerprint("ab01cd")).isEqualTo("AB:01:CD")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the certificate is found anywhere in the cause chain`() {
|
||||||
|
val certificate = selfSigned().certificate
|
||||||
|
val wrapped = IOException(SSLHandshakeException("handshake").apply {
|
||||||
|
initCause(UntrustedCertificateException(certificate, CertificateException("path")))
|
||||||
|
})
|
||||||
|
|
||||||
|
assertThat(untrustedCertificateIn(wrapped)).isEqualTo(certificate)
|
||||||
|
assertThat(untrustedCertificateIn(SSLHandshakeException("plain"))).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a self-signed server is reported with its fingerprint, not as unreachable`() {
|
||||||
|
val certificate = selfSigned()
|
||||||
|
serveTls(certificate)
|
||||||
|
server.enqueue(options())
|
||||||
|
|
||||||
|
val outcome = CalDavDiscovery(CalDavHttp.anonymous("test")).probe(server.url("/dav/"))
|
||||||
|
|
||||||
|
assertThat(outcome).isInstanceOf(CalDavDiscovery.Outcome.UntrustedCertificate::class.java)
|
||||||
|
assertThat((outcome as CalDavDiscovery.Outcome.UntrustedCertificate).certificate.fingerprint)
|
||||||
|
.isEqualTo(fingerprintOf(certificate.certificate))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a pinned certificate is accepted`() {
|
||||||
|
val certificate = selfSigned()
|
||||||
|
serveTls(certificate)
|
||||||
|
server.enqueue(options())
|
||||||
|
server.enqueue(MockResponse().setResponseCode(401))
|
||||||
|
|
||||||
|
val trust = ServerTrust.DEFAULT.withCertificate(fingerprintOf(certificate.certificate))
|
||||||
|
val outcome = CalDavDiscovery(CalDavHttp.anonymous("test", trust)).probe(server.url("/dav/"))
|
||||||
|
|
||||||
|
assertThat(outcome).isInstanceOf(CalDavDiscovery.Outcome.NeedsAuthentication::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a pinned certificate is accepted for a name it does not carry`() {
|
||||||
|
val certificate = selfSigned(host = "nas.home.arpa")
|
||||||
|
serveTls(certificate)
|
||||||
|
server.enqueue(MockResponse().setResponseCode(200))
|
||||||
|
|
||||||
|
val trust = ServerTrust.DEFAULT.withCertificate(fingerprintOf(certificate.certificate))
|
||||||
|
CalDavHttp.anonymous("test", trust)
|
||||||
|
.newCall(Request.Builder().url(server.url("/")).build())
|
||||||
|
.execute()
|
||||||
|
.use { assertThat(it.code).isEqualTo(200) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a trusted certificate for another name can be pinned too`() {
|
||||||
|
// Platform-trusted chain, wrong host: the hostname check is what fails,
|
||||||
|
// and it has to carry the certificate just the same.
|
||||||
|
val root = HeldCertificate.Builder().certificateAuthority(0).build()
|
||||||
|
val leaf = HeldCertificate.Builder().commonName("elsewhere.example")
|
||||||
|
.addSubjectAlternativeName("elsewhere.example").signedBy(root).build()
|
||||||
|
server.useHttps(
|
||||||
|
HandshakeCertificates.Builder().heldCertificate(leaf, root.certificate).build().sslSocketFactory(),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
val platform = HandshakeCertificates.Builder().addTrustedCertificate(root.certificate).build()
|
||||||
|
|
||||||
|
fun client(trust: ServerTrust): OkHttpClient {
|
||||||
|
val trustManager = PinningTrustManager(trust, platform.trustManager)
|
||||||
|
val context = SSLContext.getInstance("TLS").apply { init(null, arrayOf(trustManager), null) }
|
||||||
|
return OkHttpClient.Builder()
|
||||||
|
.sslSocketFactory(context.socketFactory, trustManager)
|
||||||
|
.hostnameVerifier(PinningHostnameVerifier(trust))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
server.enqueue(MockResponse().setResponseCode(200))
|
||||||
|
val refused = runCatching {
|
||||||
|
client(ServerTrust.DEFAULT).newCall(Request.Builder().url(server.url("/")).build()).execute()
|
||||||
|
}.exceptionOrNull()
|
||||||
|
assertThat(refused).isNotNull()
|
||||||
|
assertThat(untrustedCertificateIn(refused!!)).isEqualTo(leaf.certificate)
|
||||||
|
|
||||||
|
val trust = ServerTrust.DEFAULT.withCertificate(fingerprintOf(leaf.certificate))
|
||||||
|
client(trust).newCall(Request.Builder().url(server.url("/")).build()).execute()
|
||||||
|
.use { assertThat(it.code).isEqualTo(200) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a TLS failure that is not about a certificate is its own cause`() {
|
||||||
|
val discovery = CalDavDiscovery(OkHttpClient())
|
||||||
|
|
||||||
|
assertThat(discovery.causeOf(SSLHandshakeException("no shared cipher")))
|
||||||
|
.isEqualTo(CalDavDiscovery.Outcome.Cause.TLS)
|
||||||
|
assertThat(discovery.causeOf(ConnectException("refused")))
|
||||||
|
.isEqualTo(CalDavDiscovery.Outcome.Cause.UNREACHABLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `plain http is refused unless the account opted in`() {
|
||||||
|
server.enqueue(MockResponse().setResponseCode(200))
|
||||||
|
val request = Request.Builder().url(server.url("/")).build()
|
||||||
|
|
||||||
|
val refused = runCatching { CalDavHttp.anonymous("test").newCall(request).execute() }
|
||||||
|
assertThat(refused.exceptionOrNull()).isInstanceOf(UnknownServiceException::class.java)
|
||||||
|
|
||||||
|
CalDavHttp.anonymous("test", ServerTrust(allowCleartext = true)).newCall(request).execute()
|
||||||
|
.use { assertThat(it.code).isEqualTo(200) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an opted-in account may discover over http`() {
|
||||||
|
server.enqueue(options())
|
||||||
|
server.enqueue(MockResponse().setResponseCode(401))
|
||||||
|
val trust = ServerTrust(allowCleartext = true)
|
||||||
|
val discovery = CalDavDiscovery(CalDavHttp.anonymous("test", trust), allowCleartext = true)
|
||||||
|
|
||||||
|
assertThat(discovery.probe(server.url("/dav/")))
|
||||||
|
.isInstanceOf(CalDavDiscovery.Outcome.NeedsAuthentication::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an untrusted SRV target does not stop the ladder before one that answers`() {
|
||||||
|
val loopback = InetAddress.getByName("127.0.0.1")
|
||||||
|
fun tlsServer(certificate: HeldCertificate) = MockWebServer().apply {
|
||||||
|
useHttps(HandshakeCertificates.Builder().heldCertificate(certificate).build().sslSocketFactory(), false)
|
||||||
|
start(loopback, 0)
|
||||||
|
}
|
||||||
|
val untrusted = tlsServer(selfSigned("127.0.0.1"))
|
||||||
|
val pinned = selfSigned("127.0.0.1")
|
||||||
|
val answering = tlsServer(pinned).apply {
|
||||||
|
enqueue(options())
|
||||||
|
enqueue(MockResponse().setResponseCode(401))
|
||||||
|
}
|
||||||
|
val dns = object : DnsResolver {
|
||||||
|
override fun lookup(name: String) = DnsRecords(
|
||||||
|
srv = listOf(
|
||||||
|
SrvRecord(priority = 0, weight = 0, port = untrusted.port, target = "127.0.0.1."),
|
||||||
|
SrvRecord(priority = 10, weight = 0, port = answering.port, target = "127.0.0.1."),
|
||||||
|
),
|
||||||
|
txt = emptyList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val trust = ServerTrust.DEFAULT.withCertificate(fingerprintOf(pinned.certificate))
|
||||||
|
|
||||||
|
try {
|
||||||
|
val outcome = CalDavDiscovery(CalDavHttp.anonymous("test", trust), dns).discover("me@127.0.0.1")
|
||||||
|
assertThat(outcome).isInstanceOf(CalDavDiscovery.Outcome.NeedsAuthentication::class.java)
|
||||||
|
} finally {
|
||||||
|
untrusted.shutdown()
|
||||||
|
answering.shutdown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the email's own domain is a guess, and an SRV target is not`() {
|
||||||
|
assertThat(ServiceDiscovery.candidatesFor("me@example.com").all { it.guessed }).isTrue()
|
||||||
|
|
||||||
|
val dns = object : DnsResolver {
|
||||||
|
override fun lookup(name: String) = DnsRecords(
|
||||||
|
srv = listOf(SrvRecord(priority = 0, weight = 0, port = 443, target = "dav.example.com.")),
|
||||||
|
txt = emptyList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assertThat(ServiceDiscovery.candidatesFor("me@example.com", dns).none { it.guessed }).isTrue()
|
||||||
|
assertThat(ServiceDiscovery.candidatesFor("https://example.com").none { it.guessed }).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user