Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2320de60b | ||
|
|
62ee105f9d | ||
|
|
b1abcecd06 | ||
|
|
ba48a9821c | ||
|
|
fd8e4c9450 | ||
|
|
3452fd631b | ||
|
|
b8264e336f | ||
|
|
2c450d300f | ||
|
|
df1fb4e46b | ||
|
|
63c7aa64e2 | ||
|
|
c26ff5909b | ||
|
|
c1219a434d | ||
|
|
5dc1d49295 | ||
|
|
08c297f30a | ||
|
|
1d6c2a0d59 | ||
|
|
2a1230c0ca | ||
|
|
cd3b33c959 | ||
|
|
8175d58dcd | ||
|
|
05696b2991 | ||
|
|
23f6156e71 | ||
|
|
c1b65c0c86 | ||
|
|
b5ac279b94 | ||
|
|
8fba3f01a4 | ||
|
|
3b73dd4c53 | ||
|
|
01850a7b74 | ||
|
|
28e646fb31 | ||
|
|
6e3cc4bdbd | ||
|
|
b6185ed53c | ||
|
|
9338c64dc7 | ||
|
|
9a72fb0880 | ||
|
|
24e978efc3 | ||
|
|
2740dd5562 | ||
|
|
8dbfd07d10 | ||
|
|
3e1a84ab7d |
@@ -7,6 +7,27 @@ All notable changes to this project are documented here. The format follows
|
||||
|
||||
## [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, picked from launcher
|
||||
previews as in Calendula (#104).
|
||||
- Reminder notifications show the task's description (#94).
|
||||
|
||||
### Changed
|
||||
- Adding or opening a task from a widget returns you to the home screen
|
||||
afterwards (#93).
|
||||
- A swipe that starts at the screen edge no longer swipes the task (#90).
|
||||
|
||||
## [1.2.0] - 2026-10-26
|
||||
|
||||
### Added
|
||||
- A reminder can count from the task's start as well as from its due date, and
|
||||
tasks with only a start date get reminders too. Default reminders can mix
|
||||
|
||||
@@ -33,8 +33,8 @@ android {
|
||||
// (1.0.x: 1.0.0 -> 10000; from 1.1.0: 1.1.0-beta.1 -> 1010001,
|
||||
// 1.1.0 -> 1010099), and CI fails if the committed one doesn't match.
|
||||
// See docs/RELEASING.md.
|
||||
versionCode = 1020002
|
||||
versionName = "1.2.0-beta.2"
|
||||
versionCode = 1030002
|
||||
versionName = "1.3.0-beta.2"
|
||||
|
||||
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 {
|
||||
const val TEST_DB = "migration-test.db"
|
||||
}
|
||||
|
||||
@@ -13,10 +13,13 @@ import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
import de.jeanlucmakiola.agendula.data.sync.AccountCreator
|
||||
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.LoginFlowRecord
|
||||
import de.jeanlucmakiola.agendula.data.sync.ManualSync
|
||||
import de.jeanlucmakiola.agendula.data.sync.OkHttpCalDavGateway
|
||||
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.RemoteLists
|
||||
import de.jeanlucmakiola.agendula.data.sync.SyncNoticeNotifier
|
||||
@@ -79,7 +82,11 @@ abstract class SyncBindModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindSyncRequests(impl: SyncTrigger): SyncRequests
|
||||
abstract fun bindSyncRequests(impl: AccountSyncRequests): SyncRequests
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindManualSync(impl: PullSync): ManualSync
|
||||
|
||||
@Binds
|
||||
@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.TasksDatabase
|
||||
import de.jeanlucmakiola.caldav.CalDavDiscovery
|
||||
import de.jeanlucmakiola.caldav.ServerTrust
|
||||
import de.jeanlucmakiola.caldav.TaskCollection
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -16,6 +17,7 @@ import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -35,7 +37,12 @@ interface AccountCreator {
|
||||
selected: Set<TaskCollection>,
|
||||
/** An existing account to sign in again, instead of creating one. */
|
||||
reauthenticating: Long? = null,
|
||||
/** What the user agreed to trust on the way here; saved with the account. */
|
||||
trust: ServerTrust = ServerTrust.DEFAULT,
|
||||
): 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 notices: SyncNoticeStore,
|
||||
private val collectionSupport: CollectionSupportStore,
|
||||
private val serverTrust: ServerTrustStore,
|
||||
private val gateway: CalDavGateway,
|
||||
private val availability: SyncAvailability,
|
||||
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. */
|
||||
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. */
|
||||
fun observeSyncedLists(): Flow<List<TaskListEntity>> = database.taskLists().observeSynced()
|
||||
|
||||
@@ -115,6 +128,7 @@ class AccountRepository @Inject constructor(
|
||||
found: CalDavDiscovery.Outcome.Found,
|
||||
selected: Set<TaskCollection>,
|
||||
reauthenticating: Long?,
|
||||
trust: ServerTrust,
|
||||
): Outcome = withContext(io) {
|
||||
if (!availability.accountsUsable()) return@withContext Outcome.ExternalStorage
|
||||
// "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.
|
||||
val target = reauthenticating?.let { database.accounts().account(it) }
|
||||
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
|
||||
// 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
|
||||
// working credential.
|
||||
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
|
||||
@@ -180,6 +194,15 @@ class AccountRepository @Inject constructor(
|
||||
inserted += attach(id, selected)
|
||||
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)) {
|
||||
// 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
|
||||
* left behind rather than inserting a second copy.
|
||||
@@ -298,6 +323,7 @@ class AccountRepository @Inject constructor(
|
||||
val outcome = gateway.discover(
|
||||
principal.toString(),
|
||||
CalDavGateway.Credentials(username, password, principal),
|
||||
serverTrust.get(accountId),
|
||||
)
|
||||
) {
|
||||
is CalDavDiscovery.Outcome.Found -> Collections.Found(outcome.collections, attached)
|
||||
@@ -306,6 +332,10 @@ class AccountRepository @Inject constructor(
|
||||
-> Collections.NeedsSignIn
|
||||
is CalDavDiscovery.Outcome.NotCalDav -> 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,
|
||||
found: CalDavDiscovery.Outcome.Found,
|
||||
selected: Set<TaskCollection>,
|
||||
trust: ServerTrust,
|
||||
): Outcome {
|
||||
val accountId = existing.id
|
||||
if (!credentials.put(accountId, appPassword)) {
|
||||
@@ -394,6 +425,11 @@ class AccountRepository @Inject constructor(
|
||||
"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) {
|
||||
database.runInTransaction {
|
||||
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,
|
||||
* which is exactly where it came from.
|
||||
*/
|
||||
private fun rollback(accountId: Long, inserted: List<Long>) = database.runInTransaction {
|
||||
inserted.forEach { database.taskLists().delete(it) }
|
||||
database.accounts().delete(accountId)
|
||||
private suspend fun rollback(accountId: Long, inserted: List<Long>) {
|
||||
database.runInTransaction {
|
||||
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.
|
||||
*
|
||||
@@ -530,6 +578,7 @@ class AccountRepository @Inject constructor(
|
||||
// The same reasoning, for what the server said it would let us
|
||||
// create: keyed by an id nothing will ever mention again.
|
||||
collectionSupport.forget(accountId)
|
||||
serverTrust.forget(accountId)
|
||||
credentials.clear(accountId)
|
||||
database.accounts().delete(accountId)
|
||||
accounts.find(displayName)?.let { accounts.remove(it) }
|
||||
@@ -557,6 +606,7 @@ class AccountRepository @Inject constructor(
|
||||
try {
|
||||
gateway.revokeAppPassword(
|
||||
CalDavGateway.Credentials(username, password, origin),
|
||||
serverTrust.get(accountId),
|
||||
)
|
||||
} catch (_: CancellationException) {
|
||||
// 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.CalDavHttp
|
||||
import de.jeanlucmakiola.caldav.NextcloudLoginFlow
|
||||
import de.jeanlucmakiola.caldav.ServerTrust
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.job
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -25,12 +26,22 @@ import javax.inject.Singleton
|
||||
interface CalDavGateway {
|
||||
|
||||
/** 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. */
|
||||
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.
|
||||
@@ -38,7 +49,10 @@ interface CalDavGateway {
|
||||
* Without it, uninstalling never revokes anything — the credential we minted
|
||||
* 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.
|
||||
@@ -49,7 +63,10 @@ interface CalDavGateway {
|
||||
* principal, and a subpath install's `https://host/nextcloud/` would collapse
|
||||
* 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. */
|
||||
data class Credentials(val username: String, val password: String, val origin: HttpUrl)
|
||||
@@ -71,17 +88,19 @@ class OkHttpCalDavGateway @Inject constructor(
|
||||
override suspend fun discover(
|
||||
target: String,
|
||||
credentials: CalDavGateway.Credentials?,
|
||||
trust: ServerTrust,
|
||||
): CalDavDiscovery.Outcome = withContext(io) {
|
||||
val client = credentials?.let {
|
||||
CalDavHttp.authenticated(userAgent, it.username, it.password, it.origin)
|
||||
} ?: CalDavHttp.anonymous(userAgent)
|
||||
CalDavHttp.authenticated(userAgent, it.username, it.password, it.origin, trust)
|
||||
} ?: CalDavHttp.anonymous(userAgent, trust)
|
||||
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) {
|
||||
NextcloudLoginFlow(CalDavHttp.anonymous(userAgent), userAgent)
|
||||
NextcloudLoginFlow(CalDavHttp.anonymous(userAgent, trust), userAgent)
|
||||
.start(server, now())
|
||||
.getOrNull()
|
||||
}
|
||||
@@ -93,9 +112,12 @@ class OkHttpCalDavGateway @Inject constructor(
|
||||
* 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.
|
||||
*/
|
||||
override suspend fun pollLoginFlow(flow: NextcloudLoginFlow.Flow): NextcloudLoginFlow.PollResult =
|
||||
override suspend fun pollLoginFlow(
|
||||
flow: NextcloudLoginFlow.Flow,
|
||||
trust: ServerTrust,
|
||||
): NextcloudLoginFlow.PollResult =
|
||||
withContext(io) {
|
||||
val client = CalDavHttp.anonymous(userAgent).newBuilder()
|
||||
val client = CalDavHttp.anonymous(userAgent, trust).newBuilder()
|
||||
.callTimeout(POLL_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.build()
|
||||
NextcloudLoginFlow(client, userAgent).poll(flow, now())
|
||||
@@ -103,18 +125,20 @@ class OkHttpCalDavGateway @Inject constructor(
|
||||
|
||||
override suspend fun revokeAppPassword(
|
||||
credentials: CalDavGateway.Credentials,
|
||||
trust: ServerTrust,
|
||||
): Boolean = withContext(io) {
|
||||
val client = CalDavHttp.authenticated(
|
||||
userAgent, credentials.username, credentials.password, credentials.origin,
|
||||
userAgent, credentials.username, credentials.password, credentials.origin, trust,
|
||||
)
|
||||
AppPassword.revoke(client, credentials.origin)
|
||||
}
|
||||
|
||||
override suspend fun revokeIssuedAppPassword(
|
||||
credentials: CalDavGateway.Credentials,
|
||||
trust: ServerTrust,
|
||||
): Boolean = withContext(io) {
|
||||
val client = CalDavHttp.authenticated(
|
||||
userAgent, credentials.username, credentials.password, credentials.origin,
|
||||
userAgent, credentials.username, credentials.password, credentials.origin, trust,
|
||||
)
|
||||
AppPassword.revokeAt(client, credentials.origin)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
|
||||
import de.jeanlucmakiola.caldav.NextcloudLoginFlow
|
||||
import de.jeanlucmakiola.caldav.ServerTrust
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
@@ -23,8 +24,8 @@ import javax.inject.Singleton
|
||||
*/
|
||||
interface LoginFlowRecord {
|
||||
|
||||
/** Called **before** the browser is handed the URL. */
|
||||
suspend fun remember(flow: NextcloudLoginFlow.Flow)
|
||||
/** Called **before** the browser is handed the URL; [trust] is what reaching it took. */
|
||||
suspend fun remember(flow: NextcloudLoginFlow.Flow, trust: ServerTrust = ServerTrust.DEFAULT)
|
||||
|
||||
/** The flow is over, however it ended. */
|
||||
suspend fun forget()
|
||||
@@ -58,12 +59,13 @@ class PendingLoginFlowStore @Inject constructor(
|
||||
private var reclaimed = false
|
||||
|
||||
/** 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 ->
|
||||
prefs[LOGIN_URL] = flow.loginUrl.toString()
|
||||
prefs[POLL_ENDPOINT] = flow.pollEndpoint.toString()
|
||||
prefs[POLL_TOKEN] = flow.pollToken
|
||||
prefs[DEADLINE] = flow.deadlineEpochSeconds
|
||||
TRUST.write(prefs, trust)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +76,7 @@ class PendingLoginFlowStore @Inject constructor(
|
||||
prefs.remove(POLL_ENDPOINT)
|
||||
prefs.remove(POLL_TOKEN)
|
||||
prefs.remove(DEADLINE)
|
||||
TRUST.remove(prefs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +101,8 @@ class PendingLoginFlowStore @Inject constructor(
|
||||
reclaimed = true
|
||||
}
|
||||
val flow = pending() ?: return
|
||||
when (val result = gateway.pollLoginFlow(flow)) {
|
||||
val trust = trust()
|
||||
when (val result = gateway.pollLoginFlow(flow, trust)) {
|
||||
is NextcloudLoginFlow.PollResult.Approved -> {
|
||||
// Cleared first: a revocation that fails must not leave a token
|
||||
// that would be polled again, and the 200 is already spent.
|
||||
@@ -109,6 +113,7 @@ class PendingLoginFlowStore @Inject constructor(
|
||||
password = result.credentials.appPassword,
|
||||
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 {
|
||||
val LOGIN_URL = stringPreferencesKey("login_flow_url")
|
||||
val POLL_ENDPOINT = stringPreferencesKey("login_flow_poll_endpoint")
|
||||
val POLL_TOKEN = stringPreferencesKey("login_flow_poll_token")
|
||||
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.DavCollectionAdmin
|
||||
import de.jeanlucmakiola.caldav.ResourceNames
|
||||
import de.jeanlucmakiola.caldav.ServerTrust
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.async
|
||||
@@ -53,6 +54,7 @@ class RemoteListRepository @Inject constructor(
|
||||
private val accountState: AccountStateStore,
|
||||
private val resolver: ProviderResolver,
|
||||
private val push: PushStore,
|
||||
private val trust: ServerTrustStore,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : RemoteLists {
|
||||
|
||||
@@ -252,11 +254,11 @@ class RemoteListRepository @Inject constructor(
|
||||
val origin = account.principalUrl?.toHttpUrlOrNull() ?: return null
|
||||
val password = (credentials.get(account.id) as? CredentialStore.Secret.Present)?.value
|
||||
?: 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 =
|
||||
CalDavHttp.authenticated(USER_AGENT, username, password, origin)
|
||||
private fun client(username: String, password: String, origin: HttpUrl, trust: ServerTrust): OkHttpClient =
|
||||
CalDavHttp.authenticated(USER_AGENT, username, password, origin, trust)
|
||||
|
||||
private suspend fun forgetPerListState(listId: Long) {
|
||||
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 availability: SyncAvailability,
|
||||
private val push: PushRegistrar,
|
||||
private val trust: ServerTrustStore,
|
||||
@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 reports = syncCollections(account) { url ->
|
||||
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(
|
||||
"SSLHandshakeException",
|
||||
"SSLPeerUnverifiedException",
|
||||
"UntrustedHostException",
|
||||
"CertPathValidatorException",
|
||||
"CertificateException",
|
||||
"SSLException",
|
||||
|
||||
@@ -35,11 +35,7 @@ import kotlin.time.Duration.Companion.seconds
|
||||
@Singleton
|
||||
class SyncTrigger @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : SyncRequests {
|
||||
|
||||
override fun syncNow(accountName: String) {
|
||||
enqueue(accountName)
|
||||
}
|
||||
) {
|
||||
|
||||
/**
|
||||
* 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.sync.AccountStateStore
|
||||
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.SyncReport
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
|
||||
@@ -48,6 +49,7 @@ class PushRegistrar @Inject constructor(
|
||||
private val credentials: CredentialStore,
|
||||
private val accountState: AccountStateStore,
|
||||
private val availability: SyncAvailability,
|
||||
private val trust: ServerTrustStore,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) {
|
||||
|
||||
@@ -231,7 +233,7 @@ class PushRegistrar @Inject constructor(
|
||||
val username = account.username ?: return null
|
||||
val origin = account.principalUrl?.toHttpUrlOrNull() ?: 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()
|
||||
.callTimeout(CALL_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.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.CloudSync
|
||||
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.HistoryToggleOff
|
||||
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.SyncProblem
|
||||
import androidx.compose.material3.Button
|
||||
@@ -26,6 +29,7 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.tasks.room.AccountEntity
|
||||
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.caldav.ServerCertificate
|
||||
import de.jeanlucmakiola.caldav.ServerTrust
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedHint
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
@@ -75,6 +83,8 @@ internal fun AccountDetailScreen(
|
||||
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
|
||||
val usable by viewModel.accountsUsable.collectAsStateWithLifecycle()
|
||||
val push by viewModel.push.collectAsStateWithLifecycle()
|
||||
val trust by remember(accountId) { viewModel.trust(accountId) }
|
||||
.collectAsStateWithLifecycle(ServerTrust.DEFAULT)
|
||||
OnResume(viewModel::refreshPush)
|
||||
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
|
||||
// shown on the accounts list rather than here.
|
||||
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) {
|
||||
AccountHero(identity)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
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 ->
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.accounts_last_sync),
|
||||
@@ -104,6 +136,28 @@ internal fun AccountDetailScreen(
|
||||
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
|
||||
push?.let { pushState ->
|
||||
add { position ->
|
||||
@@ -115,7 +169,7 @@ internal fun AccountDetailScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
if (shown.needsSignIn) {
|
||||
if (shown.offersSignIn) {
|
||||
add { position ->
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.accounts_sign_in_again),
|
||||
@@ -124,7 +178,8 @@ internal fun AccountDetailScreen(
|
||||
onClick = { onSignInAgain(account) },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
}
|
||||
if (!shown.needsSignIn) {
|
||||
add { position ->
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.accounts_sync_now),
|
||||
@@ -480,3 +535,33 @@ private fun errorTitle(text: String): AnnotatedString {
|
||||
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?,
|
||||
)
|
||||
|
||||
/** 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 {
|
||||
val url = principalUrl?.toHttpUrlOrNull()
|
||||
val provider = url?.let { CalDavProvider.forPrincipal(it) }
|
||||
@@ -53,7 +61,7 @@ internal fun AccountEntity.identity(): AccountIdentity {
|
||||
provider = provider,
|
||||
// The fallback is the stored name: an account with no principal URL
|
||||
// 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,
|
||||
user = username?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
|
||||
@@ -119,7 +119,7 @@ internal fun AccountsScreen(
|
||||
when {
|
||||
row.removing -> CircularProgressIndicator(Modifier.size(20.dp))
|
||||
!usable -> Unit
|
||||
row.needsSignIn -> IconButton(onClick = { onSignInAgain(account) }) {
|
||||
row.offersSignIn -> IconButton(onClick = { onSignInAgain(account) }) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Rounded.Login,
|
||||
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.sync.AccountRepository
|
||||
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.SyncFailure
|
||||
import de.jeanlucmakiola.agendula.data.sync.SyncNotice
|
||||
import de.jeanlucmakiola.agendula.data.sync.SyncNoticeStore
|
||||
import de.jeanlucmakiola.agendula.data.sync.SyncTrigger
|
||||
@@ -30,6 +32,7 @@ import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import de.jeanlucmakiola.caldav.CalDavDiscovery
|
||||
import de.jeanlucmakiola.caldav.ServerTrust
|
||||
import de.jeanlucmakiola.caldav.TaskCollection
|
||||
import okhttp3.HttpUrl
|
||||
import javax.inject.Inject
|
||||
@@ -43,6 +46,7 @@ class AccountsViewModel @Inject constructor(
|
||||
private val settings: SettingsPrefs,
|
||||
private val distributors: PushDistributors,
|
||||
private val pushRegistrar: PushRegistrar,
|
||||
private val serverTrust: ServerTrustStore,
|
||||
pushStore: PushStore,
|
||||
availability: SyncAvailability,
|
||||
@IoDispatcher io: CoroutineDispatcher,
|
||||
@@ -188,7 +192,18 @@ class AccountsViewModel @Inject constructor(
|
||||
*/
|
||||
val notices: List<SyncNotice> = emptyList(),
|
||||
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.
|
||||
@@ -248,6 +263,13 @@ class AccountsViewModel @Inject constructor(
|
||||
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. */
|
||||
fun dismissNotices(account: AccountEntity) {
|
||||
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.CloudOff
|
||||
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.LockOpen
|
||||
import androidx.compose.material.icons.rounded.OpenInBrowser
|
||||
import androidx.compose.material.icons.rounded.Warning
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -179,6 +181,8 @@ internal fun AddAccountScreen(
|
||||
is AddAccountStep.Working -> WorkingStep(step)
|
||||
is AddAccountStep.EnterCredentials -> CredentialsStep(step, viewModel)
|
||||
is AddAccountStep.ConfirmBrowser -> ConfirmBrowserStep(step)
|
||||
is AddAccountStep.ConfirmCleartext -> ConfirmCleartextStep(step)
|
||||
is AddAccountStep.TrustCertificate -> TrustCertificateStep(step)
|
||||
is AddAccountStep.WaitingForBrowser -> BrowserStep(step)
|
||||
is AddAccountStep.ChooseLists -> ListsStep(step, viewModel)
|
||||
is AddAccountStep.Summary -> SummaryStep(step)
|
||||
@@ -228,7 +232,11 @@ private fun AddAccountStep.position(hasSetup: Boolean): Int? {
|
||||
return when (this) {
|
||||
is AddAccountStep.ChooseProvider -> 1
|
||||
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
|
||||
// hands the sign-in to a browser — asking first, where the URL needs
|
||||
// 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
|
||||
// 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.ConfirmCleartext -> Icons.Rounded.LockOpen
|
||||
state.step is AddAccountStep.TrustCertificate -> Icons.Rounded.GppMaybe
|
||||
state.step is AddAccountStep.WaitingForBrowser -> Icons.Rounded.OpenInBrowser
|
||||
state.step is AddAccountStep.ChooseLists -> Icons.Rounded.Checklist
|
||||
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
|
||||
is AddAccountStep.ConfirmBrowser ->
|
||||
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 ->
|
||||
R.string.add_account_lists_title to R.string.add_account_lists_body
|
||||
is AddAccountStep.Summary ->
|
||||
@@ -412,6 +426,34 @@ private fun ColumnScope.StepActions(state: AddAccountUiState, viewModel: AddAcco
|
||||
) { 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 -> {
|
||||
// A failed flow is retried by default. With no browser a retry
|
||||
// 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.NO_CALENDAR_SUPPORT -> R.string.add_account_error_no_calendar
|
||||
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.NO_CALENDARS -> R.string.add_account_error_no_calendars
|
||||
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.CalDavGateway
|
||||
import de.jeanlucmakiola.agendula.data.sync.LoginFlowRecord
|
||||
import de.jeanlucmakiola.agendula.data.sync.ServerTrustKeys
|
||||
import de.jeanlucmakiola.caldav.CalDavDiscovery
|
||||
import de.jeanlucmakiola.caldav.CalDavProvider
|
||||
import de.jeanlucmakiola.caldav.NextcloudLoginFlow
|
||||
import de.jeanlucmakiola.caldav.ServerCertificate
|
||||
import de.jeanlucmakiola.caldav.ServerQuirk
|
||||
import de.jeanlucmakiola.caldav.ServerTrust
|
||||
import de.jeanlucmakiola.caldav.ServiceDiscovery
|
||||
import de.jeanlucmakiola.caldav.TaskCollection
|
||||
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -116,6 +121,12 @@ sealed interface AddAccountStep {
|
||||
val insecure: Boolean = false,
|
||||
) : 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
|
||||
* 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. */
|
||||
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. */
|
||||
private var hostsNeedingAuth: List<String>
|
||||
get() = handle.get<ArrayList<String>>(KEY_HOSTS_NEEDING_AUTH).orEmpty()
|
||||
@@ -424,6 +465,15 @@ class AddAccountViewModel @Inject constructor(
|
||||
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. */
|
||||
@@ -498,10 +548,25 @@ class AddAccountViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
serverRoot = ServiceDiscovery.serverRootFor(input)
|
||||
discoverAddress()
|
||||
}
|
||||
|
||||
/** Discovery for the submitted address, without credentials. */
|
||||
private fun discoverAddress() {
|
||||
working(AddAccountMessage.Progress.Discovering)
|
||||
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.NeedsAuthentication -> {
|
||||
@@ -517,11 +582,71 @@ class AddAccountViewModel @Inject constructor(
|
||||
|
||||
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
|
||||
* 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
|
||||
username = step.username.trim()
|
||||
appPassword = step.password
|
||||
discoverWithCredentials()
|
||||
}
|
||||
|
||||
private fun discoverWithCredentials() {
|
||||
working(AddAccountMessage.Progress.SigningIn)
|
||||
viewModelScope.launch {
|
||||
// ⚠️ An error, not a silent unauthenticated retry. serverRootFor
|
||||
@@ -573,7 +701,11 @@ class AddAccountViewModel @Inject constructor(
|
||||
val root = serverRoot
|
||||
?: return@launch backToAddress(CalDavDiscovery.Outcome.Cause.NOT_AN_ADDRESS)
|
||||
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)
|
||||
|
||||
// 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.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. */
|
||||
private suspend fun offerSignIn() {
|
||||
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) {
|
||||
// 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) {
|
||||
browserFlow = flow
|
||||
runCatching { pendingFlow.remember(flow) }
|
||||
runCatching { pendingFlow.remember(flow, trust) }
|
||||
updateState {
|
||||
it.copy(
|
||||
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
|
||||
// own per-call budget, and the delay above stays cancellable, so
|
||||
// the loop still stops promptly.
|
||||
val result = withContext(NonCancellable) { gateway.pollLoginFlow(flow) }
|
||||
val result = withContext(NonCancellable) { gateway.pollLoginFlow(flow, trust) }
|
||||
when (result) {
|
||||
is NextcloudLoginFlow.PollResult.Approved -> {
|
||||
// ⚠️ Uncancellable. The 200 that carried this password is
|
||||
@@ -742,47 +875,7 @@ class AddAccountViewModel @Inject constructor(
|
||||
result.hostMismatch?.let { mismatch ->
|
||||
updateState { it.copy(originMismatch = mismatch) }
|
||||
}
|
||||
working(AddAccountMessage.Progress.ReadingLists)
|
||||
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)
|
||||
}
|
||||
discoverApproved(result.credentials.server)
|
||||
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.
|
||||
*
|
||||
@@ -898,6 +1037,7 @@ class AddAccountViewModel @Inject constructor(
|
||||
found = discovered,
|
||||
selected = chosen,
|
||||
reauthenticating = reauthAccountId,
|
||||
trust = trust,
|
||||
)
|
||||
}.getOrElse {
|
||||
// A constraint violation or an IO failure in DataStore must not
|
||||
@@ -1004,6 +1144,19 @@ class AddAccountViewModel @Inject constructor(
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1045,6 +1198,13 @@ class AddAccountViewModel @Inject constructor(
|
||||
reauthAccountId = null
|
||||
suggestedUsername = ""
|
||||
fallbackEmail = null
|
||||
trustLoad?.cancel()
|
||||
trustLoad = null
|
||||
trust = ServerTrust.DEFAULT
|
||||
trustAddress = null
|
||||
inheritedPins = false
|
||||
trustPrompt?.answer?.cancel()
|
||||
trustPrompt = null
|
||||
current = AddAccountUiState()
|
||||
}
|
||||
|
||||
@@ -1068,9 +1228,10 @@ class AddAccountViewModel @Inject constructor(
|
||||
private fun discardMintedPassword() {
|
||||
val credentials = minted ?: return
|
||||
minted = null
|
||||
val trust = trust
|
||||
// The scope has no exception handler, so anything escaping here takes the
|
||||
// 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
|
||||
@@ -1095,6 +1256,8 @@ class AddAccountViewModel @Inject constructor(
|
||||
// what deals with that on the next open. Recording them as
|
||||
// themselves would only mean deciding the same thing twice.
|
||||
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.ChooseLists -> STEP_LISTS
|
||||
is AddAccountStep.Summary -> STEP_SUMMARY
|
||||
@@ -1200,7 +1363,10 @@ class AddAccountViewModel @Inject constructor(
|
||||
)
|
||||
viewModelScope.launch {
|
||||
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.NotCalDav -> backToAddressOrServer(outcome.cause)
|
||||
is CalDavDiscovery.Outcome.Failed -> backToAddressOrServer(outcome.cause)
|
||||
@@ -1211,6 +1377,7 @@ class AddAccountViewModel @Inject constructor(
|
||||
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 =
|
||||
username.takeIf { it.isNotBlank() }?.let { "$it@${serverRoot?.host.orEmpty()}" }
|
||||
username.takeIf { it.isNotBlank() }?.let { "$it@${serverRoot?.authority().orEmpty()}" }
|
||||
?: typedInput
|
||||
|
||||
private fun HttpUrl.authority(): String =
|
||||
if (port == HttpUrl.defaultPort(scheme)) host else "$host:$port"
|
||||
|
||||
internal companion object {
|
||||
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_SUGGESTED_USERNAME = "add_account.suggested_username"
|
||||
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. */
|
||||
val NO_SERVER_FOUND = setOf(
|
||||
CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER,
|
||||
CalDavDiscovery.Outcome.Cause.NO_CALENDAR_SUPPORT,
|
||||
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
|
||||
is for, so they are added back here.
|
||||
|
||||
Cleartext stays off. It has been the default since API 28, and CalDavDiscovery
|
||||
refuses a typed http:// address for the same reason: credentials are never
|
||||
sent over an unencrypted connection. Any escape hatch has to be a narrow,
|
||||
warned, per-account opt-in — Play's User Data policy requires modern
|
||||
cryptography in transit — and this file is not the place for it.
|
||||
Cleartext is permitted here and gated in code instead, per account: every
|
||||
client CalDavHttp builds leaves ConnectionSpec.CLEARTEXT out unless the user
|
||||
opted that account into plain http behind an explicit warning, so OkHttp
|
||||
itself refuses an http:// URL for everyone else. This file cannot express a
|
||||
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
|
||||
*all* traffic, so any CA in the user store — a corporate MDM profile, a "free
|
||||
@@ -25,7 +28,7 @@
|
||||
sit alongside it.
|
||||
-->
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="false">
|
||||
<base-config cleartextTrafficPermitted="true">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
<certificates src="user" />
|
||||
|
||||
@@ -65,10 +65,8 @@
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<!-- MAIN/LAUNCHER lives on the two aliases below, so the launcher
|
||||
name can be switched at runtime (#104). -->
|
||||
<!-- Shared text becomes a new task's title. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
@@ -89,6 +87,32 @@
|
||||
<data android:mimeType="text/calendar" />
|
||||
</intent-filter>
|
||||
</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. -->
|
||||
<service
|
||||
|
||||
@@ -28,7 +28,6 @@ import de.jeanlucmakiola.agendula.data.di.LaunchHook
|
||||
import de.jeanlucmakiola.agendula.domain.SmartList
|
||||
import de.jeanlucmakiola.agendula.ui.imports.importIntentUri
|
||||
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.crash.CrashReportActivity
|
||||
import de.jeanlucmakiola.agendula.ui.navigation.NavRequest
|
||||
@@ -88,15 +87,15 @@ class MainActivity : ComponentActivity() {
|
||||
setContent {
|
||||
val settingsViewModel: SettingsViewModel = hiltViewModel()
|
||||
val ui by settingsViewModel.state.collectAsStateWithLifecycle()
|
||||
val darkTheme = when (ui.settings.themeMode) {
|
||||
ThemeMode.SYSTEM -> isSystemInDarkTheme()
|
||||
ThemeMode.LIGHT -> false
|
||||
ThemeMode.DARK -> true
|
||||
}
|
||||
val darkTheme = ui.settings.themeMode.isDark(isSystemInDarkTheme())
|
||||
val context = LocalContext.current
|
||||
val use24Hour = ui.settings.timeFormat.is24Hour(DateFormat.is24HourFormat(context))
|
||||
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(
|
||||
LocalUse24HourFormat provides use24Hour,
|
||||
LocalFirstDayOfWeek provides firstDayOfWeek,
|
||||
@@ -105,6 +104,7 @@ class MainActivity : ComponentActivity() {
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
navRequest = navRequest,
|
||||
onNavRequestConsumed = { navRequest = null },
|
||||
onReturnHome = ::finish,
|
||||
)
|
||||
pendingCrashReport?.let { report ->
|
||||
CrashReportDialog(
|
||||
@@ -153,6 +153,7 @@ class MainActivity : ComponentActivity() {
|
||||
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_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 SHARED_TITLE_LIMIT = 500
|
||||
private const val NO_OCCURRENCE = -1L
|
||||
@@ -161,10 +162,16 @@ class MainActivity : ComponentActivity() {
|
||||
* Opens a task's detail (reminder taps). [occurrenceStart] (epoch millis)
|
||||
* 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 {
|
||||
putExtra(EXTRA_TASK_ID, taskId)
|
||||
putExtra(EXTRA_OCCURRENCE_START, occurrenceStart ?: NO_OCCURRENCE)
|
||||
putExtra(EXTRA_RETURN_HOME, returnHome)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
|
||||
@@ -216,6 +223,7 @@ class MainActivity : ComponentActivity() {
|
||||
when (intent.action) {
|
||||
ACTION_NEW_TASK -> return NavRequest.NewTask(
|
||||
listId = intent.getLongExtra(EXTRA_PRESET_LIST_ID, -1L).takeIf { it > 0L },
|
||||
returnHome = true,
|
||||
)
|
||||
ACTION_TODAY -> return NavRequest.OpenSmart(SmartList.TODAY)
|
||||
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) {
|
||||
val shared = intent.getStringExtra(Intent.EXTRA_SUBJECT)?.takeIf { it.isNotBlank() }
|
||||
?: 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 occurrence = intent.getLongExtra(EXTRA_OCCURRENCE_START, 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
|
||||
// the app checkpoints on ON_STOP.
|
||||
.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()
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,6 +15,7 @@ import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||
import de.jeanlucmakiola.agendula.domain.TaskSection
|
||||
import de.jeanlucmakiola.agendula.domain.asTaskReminder
|
||||
import de.jeanlucmakiola.agendula.domain.asReminderLead
|
||||
import de.jeanlucmakiola.agendula.domain.TaskSortOrder
|
||||
@@ -31,7 +32,18 @@ import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
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. */
|
||||
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
|
||||
/** 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. */
|
||||
val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60)
|
||||
@@ -103,6 +116,8 @@ data class Settings(
|
||||
val hiddenFromSmartLists: Set<Long> = emptySet(),
|
||||
/** Pre-fill a new task's start with today's date. */
|
||||
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. */
|
||||
fun visibleIn(filter: TaskFilter, tasks: List<Task>): List<Task> =
|
||||
@@ -168,9 +183,18 @@ class SettingsPrefs @Inject constructor(
|
||||
.toSet()
|
||||
},
|
||||
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 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 SMART_LIST_HIDDEN = stringSetPreferencesKey("smart_list_hidden")
|
||||
val DEFAULT_START_TODAY = booleanPreferencesKey("default_start_today")
|
||||
val COLLAPSED_SECTIONS = stringSetPreferencesKey("collapsed_sections")
|
||||
val SAVED_FILTERS = stringPreferencesKey("saved_filters")
|
||||
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.R
|
||||
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.di.ApplicationScope
|
||||
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(
|
||||
context,
|
||||
task.taskId.toInt(),
|
||||
@@ -93,7 +95,10 @@ class TaskNotifier @Inject constructor(
|
||||
val notification = NotificationCompat.Builder(context, channelId)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.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)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setAutoCancel(true)
|
||||
@@ -240,3 +245,10 @@ private fun relativeDay(date: LocalDate, today: LocalDate, words: RelativeDays,
|
||||
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. */
|
||||
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")
|
||||
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")
|
||||
fun delete(accountId: Long): Int
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ data class AccountEntity(
|
||||
@ColumnInfo(name = "username") val username: String? = null,
|
||||
@ColumnInfo(name = "last_sync_at") val lastSyncAt: Instant? = 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(
|
||||
@Embedded val list: TaskListEntity,
|
||||
@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_color") val listColor: Int,
|
||||
@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,
|
||||
)
|
||||
|
||||
@@ -20,7 +20,7 @@ object RoomTaskMapper {
|
||||
color = row.list.color,
|
||||
// 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.
|
||||
accountName = row.accountDisplayName ?: LocalAccount.NAME,
|
||||
accountName = row.accountLabel ?: row.accountDisplayName ?: LocalAccount.NAME,
|
||||
accountType = if (row.list.accountId == null) LocalAccount.TYPE else CALDAV_ACCOUNT_TYPE,
|
||||
isSynced = row.list.isSynced,
|
||||
isVisible = row.list.isVisible,
|
||||
@@ -61,7 +61,7 @@ object RoomTaskMapper {
|
||||
listColor = row.listColor,
|
||||
taskColor = row.task.color,
|
||||
listName = row.listName,
|
||||
accountName = row.accountDisplayName ?: LocalAccount.NAME,
|
||||
accountName = row.accountLabel ?: row.accountDisplayName ?: LocalAccount.NAME,
|
||||
parentId = row.task.parentId,
|
||||
isRecurring = row.task.isRecurring,
|
||||
occurrenceStart = occurrenceStart,
|
||||
|
||||
@@ -29,7 +29,7 @@ interface TaskDao {
|
||||
@Query(
|
||||
"""
|
||||
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
|
||||
JOIN task_lists l ON l.id = t.list_id
|
||||
LEFT JOIN accounts a ON a.id = l.account_id
|
||||
@@ -44,7 +44,7 @@ interface TaskDao {
|
||||
@Query(
|
||||
"""
|
||||
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
|
||||
JOIN task_lists l ON l.id = t.list_id
|
||||
LEFT JOIN accounts a ON a.id = l.account_id
|
||||
@@ -56,7 +56,7 @@ interface TaskDao {
|
||||
@Query(
|
||||
"""
|
||||
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
|
||||
JOIN task_lists l ON l.id = t.list_id
|
||||
LEFT JOIN accounts a ON a.id = l.account_id
|
||||
|
||||
@@ -16,7 +16,7 @@ interface TaskListDao {
|
||||
|
||||
@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
|
||||
ORDER BY a.display_name, l.sort_order, l.name
|
||||
"""
|
||||
@@ -25,7 +25,7 @@ interface TaskListDao {
|
||||
|
||||
@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
|
||||
WHERE l.id = :listId
|
||||
"""
|
||||
|
||||
@@ -22,7 +22,7 @@ import kotlin.time.Instant
|
||||
TaskEntity::class,
|
||||
TaskAlarmEntity::class,
|
||||
],
|
||||
version = 3,
|
||||
version = 4,
|
||||
exportSchema = true,
|
||||
)
|
||||
@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,
|
||||
navRequest: NavRequest? = null,
|
||||
onNavRequestConsumed: () -> Unit = {},
|
||||
onReturnHome: () -> Unit = {},
|
||||
permissionViewModel: PermissionViewModel = hiltViewModel(),
|
||||
) {
|
||||
val permission by permissionViewModel.state.collectAsStateWithLifecycle()
|
||||
@@ -86,6 +87,7 @@ fun RootScreen(
|
||||
modifier = modifier,
|
||||
navRequest = navRequest,
|
||||
onNavRequestConsumed = onNavRequestConsumed,
|
||||
onReturnHome = onReturnHome,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -101,6 +103,7 @@ private fun ReadyGate(
|
||||
modifier: Modifier = Modifier,
|
||||
navRequest: NavRequest? = null,
|
||||
onNavRequestConsumed: () -> Unit = {},
|
||||
onReturnHome: () -> Unit = {},
|
||||
onboardingViewModel: OnboardingViewModel = hiltViewModel(),
|
||||
) {
|
||||
val done by onboardingViewModel.done.collectAsStateWithLifecycle()
|
||||
@@ -113,6 +116,7 @@ private fun ReadyGate(
|
||||
modifier = modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface),
|
||||
navRequest = navRequest,
|
||||
onNavRequestConsumed = onNavRequestConsumed,
|
||||
onReturnHome = onReturnHome,
|
||||
)
|
||||
false -> ReadableWidth(modifier) {
|
||||
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)
|
||||
(chosen as? ImportChoice.Existing)?.list
|
||||
?.takeIf { it.accountId != null && result.imported > 0 }
|
||||
?.let { runCatching { syncRequests.syncNow(it.accountName) } }
|
||||
?.let { runCatching { syncRequests.syncNow(it.accountId!!) } }
|
||||
ImportStep.Done(
|
||||
listName = listName,
|
||||
imported = result.imported,
|
||||
|
||||
@@ -97,6 +97,7 @@ import kotlinx.coroutines.delay
|
||||
import de.jeanlucmakiola.agendula.ui.common.ListColorChip
|
||||
import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat
|
||||
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.tasklist.priorityLabel
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
@@ -122,6 +123,8 @@ fun ListsScreen(
|
||||
viewModel: ListsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val syncAvailable by viewModel.syncAvailable.collectAsStateWithLifecycle()
|
||||
val syncing by viewModel.syncing.collectAsStateWithLifecycle()
|
||||
var query by rememberSaveable { mutableStateOf("") }
|
||||
var searchActive by rememberSaveable { mutableStateOf(false) }
|
||||
var newList by rememberSaveable { mutableStateOf(false) }
|
||||
@@ -173,7 +176,12 @@ fun ListsScreen(
|
||||
}
|
||||
},
|
||||
) { 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) {
|
||||
ListsUiState.Loading -> Unit // brief; avoids a flash before first emission
|
||||
ListsUiState.Failure ->
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
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.tasks.TasksRepository
|
||||
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
|
||||
@@ -105,9 +106,17 @@ private const val UPCOMING_PREVIEW = 3
|
||||
class ListsViewModel @Inject constructor(
|
||||
private val repository: TasksRepository,
|
||||
private val remoteLists: RemoteLists,
|
||||
private val manualSync: ManualSync,
|
||||
settingsPrefs: SettingsPrefs,
|
||||
) : 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
|
||||
* it would take one.
|
||||
|
||||
@@ -7,6 +7,11 @@ import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.runtime.Composable
|
||||
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.MaterialTheme
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -46,8 +51,19 @@ fun AgendulaNavHost(
|
||||
modifier: Modifier = Modifier,
|
||||
navRequest: NavRequest? = null,
|
||||
onNavRequestConsumed: () -> Unit = {},
|
||||
onReturnHome: () -> Unit = {},
|
||||
) {
|
||||
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) {
|
||||
when (navRequest) {
|
||||
@@ -71,6 +87,9 @@ fun AgendulaNavHost(
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,15 @@ import de.jeanlucmakiola.agendula.domain.SmartList
|
||||
* [AgendulaNavHost], which pushes it over whatever is showing.
|
||||
*/
|
||||
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. */
|
||||
data object OpenAccounts : NavRequest
|
||||
@@ -21,8 +28,13 @@ sealed interface NavRequest {
|
||||
/**
|
||||
* 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.
|
||||
* [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. */
|
||||
data class OpenSmart(val list: SmartList) : NavRequest
|
||||
|
||||
@@ -12,16 +12,23 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
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.TimeFormatPref
|
||||
import de.jeanlucmakiola.agendula.data.prefs.is24Hour
|
||||
import de.jeanlucmakiola.agendula.ui.common.formatMinuteOfDay
|
||||
import de.jeanlucmakiola.agendula.ui.common.localeFirstDayOfWeek
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.LauncherMark
|
||||
import de.jeanlucmakiola.floret.components.LauncherNameChooser
|
||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
@@ -38,6 +45,8 @@ internal fun AppearanceScreen(
|
||||
) {
|
||||
val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
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 showWeekStart by remember { mutableStateOf(false) }
|
||||
val systemIs24Hour = DateFormat.is24HourFormat(LocalContext.current)
|
||||
@@ -90,6 +99,36 @@ internal fun AppearanceScreen(
|
||||
position = Position.Bottom,
|
||||
onClick = { showWeekStart = true },
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_app_name),
|
||||
summary = launcherNameLabel(launcherName),
|
||||
position = Position.Alone,
|
||||
onClick = { showAppName = true },
|
||||
)
|
||||
}
|
||||
|
||||
if (showAppName) {
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.settings_app_name),
|
||||
onDismiss = { showAppName = false },
|
||||
predictiveBack = true,
|
||||
) {
|
||||
LauncherNameChooser(
|
||||
summary = stringResource(R.string.settings_app_name_hint),
|
||||
options = LauncherName.entries,
|
||||
selected = launcherName,
|
||||
label = { launcherNameLabel(it) },
|
||||
onSelect = viewModel::setLauncherName,
|
||||
mark = {
|
||||
LauncherMark(
|
||||
background = colorResource(R.color.ic_launcher_background),
|
||||
foreground = painterResource(R.drawable.ic_launcher_foreground),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showTimeFormat) {
|
||||
@@ -140,6 +179,7 @@ internal fun AppearanceScreen(
|
||||
options = ThemeMode.entries,
|
||||
selected = state.settings.themeMode,
|
||||
label = { stringResource(themeLabel(it)) },
|
||||
summary = { if (it == ThemeMode.BLACK) stringResource(R.string.settings_theme_black_summary) else null },
|
||||
onSelect = viewModel::setThemeMode,
|
||||
onDismiss = { showTheme = false },
|
||||
)
|
||||
@@ -163,8 +203,17 @@ private fun weekStartLabel(day: DayOfWeek?): String =
|
||||
/** 14:00, the sample time "Automatic" is shown with. */
|
||||
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) {
|
||||
ThemeMode.SYSTEM -> R.string.settings_theme_system
|
||||
ThemeMode.LIGHT -> R.string.settings_theme_light
|
||||
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.viewModelScope
|
||||
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.prefs.Settings
|
||||
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.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.time.DayOfWeek
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -102,7 +105,8 @@ class SettingsViewModel @Inject constructor(
|
||||
private val oneShotImport: OneShotImport,
|
||||
private val reminderScheduler: ReminderScheduler,
|
||||
private val taskNotifier: TaskNotifier,
|
||||
@IoDispatcher io: CoroutineDispatcher,
|
||||
private val launcherNameManager: LauncherNameManager,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
repository: TasksRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -217,6 +221,23 @@ class SettingsViewModel @Inject constructor(
|
||||
|
||||
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 setDynamicColor(enabled: Boolean) = viewModelScope.launch { prefs.setDynamicColor(enabled) }
|
||||
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.TaskSections
|
||||
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.lists.ListEditorSheet
|
||||
import de.jeanlucmakiola.agendula.ui.lists.listWriteFailureMessage
|
||||
@@ -144,6 +146,8 @@ fun TaskListScreen(
|
||||
viewModel: TaskListViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val syncAvailable by viewModel.syncAvailable.collectAsStateWithLifecycle()
|
||||
val syncing by viewModel.syncing.collectAsStateWithLifecycle()
|
||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
||||
val content = state as? TaskListUiState.Content
|
||||
val list = content?.list
|
||||
@@ -247,13 +251,21 @@ fun TaskListScreen(
|
||||
},
|
||||
) { inner ->
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
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,
|
||||
)
|
||||
SyncPullBox(
|
||||
available = syncAvailable,
|
||||
syncing = syncing,
|
||||
onSync = viewModel::syncAll,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
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
|
||||
// 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.
|
||||
val showListName = filter is TaskFilter.Smart || filter is TaskFilter.Tagged ||
|
||||
(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
|
||||
// grouped from the same flat task set the list already holds.
|
||||
var expandedParents by rememberSaveable { mutableStateOf(emptySet<Long>()) }
|
||||
@@ -420,23 +430,20 @@ private fun TaskListBody(
|
||||
}
|
||||
|
||||
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) {
|
||||
stickyHeader(key = "hdr-${section.section}") {
|
||||
if (collapsible) {
|
||||
CollapsibleSectionHeader(
|
||||
label = sectionLabel(section.section),
|
||||
count = section.tasks.size,
|
||||
expanded = completedExpanded,
|
||||
onToggle = { completedExpanded = !completedExpanded },
|
||||
onClear = onClearCompleted,
|
||||
)
|
||||
} else {
|
||||
SectionHeader(sectionLabel(section.section))
|
||||
}
|
||||
CollapsibleSectionHeader(
|
||||
label = sectionLabel(section.section),
|
||||
count = section.tasks.size,
|
||||
expanded = expanded,
|
||||
onToggle = { viewModel.setSectionCollapsed(section.section, expanded) },
|
||||
onClear = onClearCompleted.takeIf { section.section == TaskSection.COMPLETED },
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!collapsible || completedExpanded) {
|
||||
if (expanded) {
|
||||
// Flatten the section into one grouped run: each parent, followed
|
||||
// by its subtasks when expanded. Each row's corners then come from
|
||||
// its place in the whole run (positionOf below) — so the children
|
||||
@@ -655,7 +662,7 @@ private fun TaskRow(
|
||||
|
||||
SwipeToDismissBox(
|
||||
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 +
|
||||
// icon reveal as you swipe rather than only once the threshold is crossed.
|
||||
backgroundContent = { SwipeBackground(dismissState.dismissDirection, task.isCompleted) },
|
||||
@@ -1037,7 +1044,7 @@ private fun SubtaskRow(
|
||||
val gap = if (lastInRun) Modifier else Modifier.padding(bottom = 2.dp)
|
||||
SwipeToDismissBox(
|
||||
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) },
|
||||
) {
|
||||
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)
|
||||
}
|
||||
|
||||
@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. */
|
||||
@Composable
|
||||
private fun CollapsibleSectionHeader(
|
||||
|
||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.agendula.ui.tasklist
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
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.sync.RemoteLists
|
||||
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.TaskForm
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.agendula.domain.TaskSection
|
||||
import de.jeanlucmakiola.agendula.domain.TaskSortOrder
|
||||
import de.jeanlucmakiola.agendula.domain.comparator
|
||||
import de.jeanlucmakiola.agendula.ui.lists.ListWriteFailure
|
||||
import de.jeanlucmakiola.agendula.ui.lists.asFailure
|
||||
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
||||
import de.jeanlucmakiola.agendula.data.sync.ManualSync
|
||||
import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
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. */
|
||||
val bottomAddBar: Boolean = false,
|
||||
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. */
|
||||
val hiddenFromSmartLists: Boolean = false,
|
||||
/** Tags the unnarrowed view carries, offered as filter chips. */
|
||||
@@ -89,9 +94,17 @@ class TaskListViewModel @Inject constructor(
|
||||
private val repository: TasksRepository,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
private val remoteLists: RemoteLists,
|
||||
private val manualSync: ManualSync,
|
||||
@ApplicationScope private val appScope: CoroutineScope,
|
||||
) : 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 tagFilter = MutableStateFlow<Set<String>>(emptySet())
|
||||
@@ -144,6 +157,7 @@ class TaskListViewModel @Inject constructor(
|
||||
showAddSubtaskRow = settings.showAddSubtaskRow,
|
||||
bottomAddBar = settings.bottomAddBar,
|
||||
sortOrder = settings.sortOrder,
|
||||
collapsedSections = settings.collapsedSections,
|
||||
hiddenFromSmartLists = st.list?.id?.let { it in settings.hiddenFromSmartLists } ?: false,
|
||||
)
|
||||
} else {
|
||||
@@ -277,6 +291,9 @@ class TaskListViewModel @Inject constructor(
|
||||
fun setHiddenFromSmartLists(listId: Long, hidden: Boolean) =
|
||||
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) }
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,6 +15,7 @@ import de.jeanlucmakiola.floret.identity.FloretExpressiveTheme
|
||||
fun AgendulaTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = true,
|
||||
pureBlack: Boolean = false,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
FloretExpressiveTheme(
|
||||
@@ -22,6 +23,7 @@ fun AgendulaTheme(
|
||||
darkScheme = AgendulaDarkFallback,
|
||||
darkTheme = darkTheme,
|
||||
dynamicColor = dynamicColor,
|
||||
pureBlack = pureBlack,
|
||||
typography = AgendulaTypography,
|
||||
content = content,
|
||||
)
|
||||
|
||||
@@ -328,7 +328,7 @@ internal fun AgendaTaskRow(
|
||||
val context = LocalContext.current
|
||||
val title = row.title.ifBlank { context.getString(R.string.task_untitled) }
|
||||
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 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.data.prefs.Settings
|
||||
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.domain.SavedFilter
|
||||
import de.jeanlucmakiola.agendula.domain.SmartList
|
||||
@@ -135,12 +134,12 @@ class TaskWidgetConfigActivity : ComponentActivity() {
|
||||
rowColor = parseWidgetRowColor(state[WIDGET_ROW_COLOR_KEY])
|
||||
selectedListIds = parseWidgetListIds(state[WIDGET_LIST_IDS_KEY])
|
||||
}
|
||||
val dark = when (settings.themeMode) {
|
||||
ThemeMode.SYSTEM -> isSystemInDarkTheme()
|
||||
ThemeMode.LIGHT -> false
|
||||
ThemeMode.DARK -> true
|
||||
}
|
||||
AgendulaTheme(darkTheme = dark, dynamicColor = settings.dynamicColor) {
|
||||
val dark = settings.themeMode.isDark(isSystemInDarkTheme())
|
||||
AgendulaTheme(
|
||||
darkTheme = dark,
|
||||
dynamicColor = settings.dynamicColor,
|
||||
pureBlack = settings.themeMode.isPureBlack,
|
||||
) {
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {}
|
||||
WidgetFilterPicker(
|
||||
kind = kind,
|
||||
|
||||
@@ -339,7 +339,7 @@ private fun ListDot(color: Int) {
|
||||
}
|
||||
|
||||
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 =
|
||||
actionRunCallback<CompleteTaskAction>(
|
||||
|
||||
@@ -82,7 +82,7 @@ internal fun effectiveThemeMode(appThemeMode: ThemeMode, override: WidgetThemeMo
|
||||
override ?: when (appThemeMode) {
|
||||
ThemeMode.SYSTEM -> WidgetThemeMode.SYSTEM
|
||||
ThemeMode.LIGHT -> WidgetThemeMode.LIGHT
|
||||
ThemeMode.DARK -> WidgetThemeMode.DARK
|
||||
ThemeMode.DARK, ThemeMode.BLACK -> WidgetThemeMode.DARK
|
||||
}
|
||||
|
||||
/** The smart lists a widget can show, in picker order. */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<resources>
|
||||
<string name="app_name">Agendula</string>
|
||||
<string name="app_name_tasks_alias">Tasks</string>
|
||||
|
||||
<!-- Generic -->
|
||||
<string name="task_untitled">Untitled task</string>
|
||||
@@ -448,6 +449,10 @@
|
||||
<string name="settings_theme_system">Follow system</string>
|
||||
<string name="settings_theme_light">Light</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_hint">Use colours from your wallpaper</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_never_synced">Never synced</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_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>
|
||||
@@ -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_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_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_signing_in">Signing in</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_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_read_only">Read-only</string>
|
||||
<string name="add_account_lists_shared">Shared with you</string>
|
||||
|
||||
@@ -4,6 +4,7 @@ import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
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.Outcome
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
|
||||
@@ -26,6 +27,9 @@ object SyncModule {
|
||||
|
||||
@Provides
|
||||
fun provideRemoteLists(): RemoteLists = NoRemoteLists
|
||||
|
||||
@Provides
|
||||
fun provideManualSync(): ManualSync = ManualSync.None
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
@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`() {
|
||||
assertThat(readyWithTheme(ThemeMode.DARK).effectiveThemeMode(null)).isEqualTo(WidgetThemeMode.DARK)
|
||||
assertThat(readyWithTheme(ThemeMode.SYSTEM).effectiveThemeMode(null)).isEqualTo(WidgetThemeMode.SYSTEM)
|
||||
assertThat(readyWithTheme(ThemeMode.BLACK).effectiveThemeMode(null)).isEqualTo(WidgetThemeMode.DARK)
|
||||
}
|
||||
|
||||
@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.CalDavProvider
|
||||
import de.jeanlucmakiola.caldav.NextcloudLoginFlow
|
||||
import de.jeanlucmakiola.caldav.ServerCertificate
|
||||
import de.jeanlucmakiola.caldav.ServerQuirk
|
||||
import de.jeanlucmakiola.caldav.ServerTrust
|
||||
import de.jeanlucmakiola.caldav.TaskCollection
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -1307,6 +1309,15 @@ class AddAccountViewModelTest {
|
||||
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
|
||||
fun `a completed add reports the account it created`() = runTest(dispatcher) {
|
||||
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:
|
||||
* address → credentials → discovery → lists. Credentials are captured on
|
||||
* submit, so going straight from the address to a `Found` leaves the
|
||||
* 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.loginFlow = null
|
||||
|
||||
val vm = viewModel()
|
||||
vm.enterAddress("https://cloud.example.com/")
|
||||
vm.enterAddress(address)
|
||||
vm.onAddressSubmitted()
|
||||
advanceUntilIdle()
|
||||
|
||||
@@ -1465,7 +1708,11 @@ class AddAccountViewModelTest {
|
||||
)
|
||||
|
||||
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 discoveryOutcomes = ArrayDeque<CalDavDiscovery.Outcome>()
|
||||
@@ -1475,8 +1722,9 @@ class AddAccountViewModelTest {
|
||||
override suspend fun discover(
|
||||
target: String,
|
||||
credentials: CalDavGateway.Credentials?,
|
||||
trust: ServerTrust,
|
||||
): CalDavDiscovery.Outcome {
|
||||
discoveries += Call(target, credentials)
|
||||
discoveries += Call(target, credentials, trust)
|
||||
return discoveryOutcomes.removeFirstOrNull()
|
||||
?: CalDavDiscovery.Outcome.Failed(
|
||||
CalDavDiscovery.Outcome.Cause.SERVER_ERROR,
|
||||
@@ -1486,17 +1734,18 @@ class AddAccountViewModelTest {
|
||||
|
||||
var loginFlowsStarted = 0
|
||||
|
||||
override suspend fun startLoginFlow(server: HttpUrl): NextcloudLoginFlow.Flow? {
|
||||
override suspend fun startLoginFlow(server: HttpUrl, trust: ServerTrust): NextcloudLoginFlow.Flow? {
|
||||
loginFlowsStarted++
|
||||
return loginFlow
|
||||
}
|
||||
|
||||
override suspend fun pollLoginFlow(flow: NextcloudLoginFlow.Flow) =
|
||||
override suspend fun pollLoginFlow(flow: NextcloudLoginFlow.Flow, trust: ServerTrust) =
|
||||
pollResults.removeFirstOrNull() ?: NextcloudLoginFlow.PollResult.Pending
|
||||
|
||||
/** Revocation is best effort and the flow never depends on it. */
|
||||
override suspend fun revokeAppPassword(
|
||||
credentials: CalDavGateway.Credentials,
|
||||
trust: ServerTrust,
|
||||
): Boolean = true
|
||||
|
||||
val revoked = mutableListOf<CalDavGateway.Credentials>()
|
||||
@@ -1504,6 +1753,7 @@ class AddAccountViewModelTest {
|
||||
|
||||
override suspend fun revokeIssuedAppPassword(
|
||||
credentials: CalDavGateway.Credentials,
|
||||
trust: ServerTrust,
|
||||
): Boolean {
|
||||
revoked += credentials
|
||||
return revokeSucceeds
|
||||
@@ -1515,7 +1765,7 @@ class AddAccountViewModelTest {
|
||||
val log = mutableListOf<String>()
|
||||
var remembered: NextcloudLoginFlow.Flow? = null
|
||||
|
||||
override suspend fun remember(flow: NextcloudLoginFlow.Flow) {
|
||||
override suspend fun remember(flow: NextcloudLoginFlow.Flow, trust: ServerTrust) {
|
||||
remembered = flow
|
||||
log += "remember"
|
||||
}
|
||||
@@ -1531,6 +1781,11 @@ class AddAccountViewModelTest {
|
||||
val reauthenticated = mutableListOf<Long?>()
|
||||
var thrown: Throwable? = 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(
|
||||
displayName: String,
|
||||
@@ -1539,8 +1794,10 @@ class AddAccountViewModelTest {
|
||||
found: CalDavDiscovery.Outcome.Found,
|
||||
selected: Set<TaskCollection>,
|
||||
reauthenticating: Long?,
|
||||
trust: ServerTrust,
|
||||
): AccountRepository.Outcome {
|
||||
reauthenticated += reauthenticating
|
||||
savedTrust += trust
|
||||
thrown?.let { throw it }
|
||||
outcome?.let { return it }
|
||||
created += displayName
|
||||
|
||||
@@ -32,5 +32,6 @@ dependencies {
|
||||
testImplementation(libs.xpp3)
|
||||
testImplementation(libs.junit4)
|
||||
testImplementation(libs.okhttp.mockwebserver)
|
||||
testImplementation(libs.okhttp.tls)
|
||||
testImplementation(libs.truth)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import java.io.IOException
|
||||
import javax.net.ssl.SSLException
|
||||
|
||||
/**
|
||||
* RFC 6764 discovery: from what the user typed to the list of task collections.
|
||||
@@ -44,6 +45,8 @@ class CalDavDiscovery(
|
||||
val url: HttpUrl,
|
||||
val reason: String,
|
||||
val needsAuthentication: Boolean,
|
||||
/** Set when the home set's host presented a certificate the user could trust. */
|
||||
val certificate: ServerCertificate? = null,
|
||||
)
|
||||
|
||||
sealed interface Outcome {
|
||||
@@ -85,6 +88,12 @@ class CalDavDiscovery(
|
||||
|
||||
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.
|
||||
*
|
||||
@@ -105,9 +114,12 @@ class CalDavDiscovery(
|
||||
/** Speaks WebDAV but not CalDAV — a file-sharing endpoint, say. */
|
||||
NO_CALENDAR_SUPPORT,
|
||||
|
||||
/** Nothing answered: DNS, connection refused, TLS, timeout. */
|
||||
/** Nothing answered: DNS, connection refused, timeout. */
|
||||
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. */
|
||||
INSECURE,
|
||||
|
||||
@@ -128,12 +140,13 @@ class CalDavDiscovery(
|
||||
* 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.
|
||||
*/
|
||||
private fun causeOf(error: Throwable): Outcome.Cause = when {
|
||||
internal fun causeOf(error: Throwable): Outcome.Cause = when {
|
||||
error is HttpException -> when (error.code) {
|
||||
METHOD_NOT_ALLOWED, NOT_IMPLEMENTED, NOT_FOUND -> Outcome.Cause.NOT_A_DAV_SERVER
|
||||
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
|
||||
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
|
||||
* have no credentials for it, and continuing down the ladder would replace a
|
||||
* 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 {
|
||||
ServiceDiscovery.asBaseUrl(input)?.let { typed ->
|
||||
@@ -161,13 +179,31 @@ class CalDavDiscovery(
|
||||
}
|
||||
|
||||
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) {
|
||||
if (candidate.url.originKey() in refusedOrigins) continue
|
||||
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
|
||||
}
|
||||
}
|
||||
return lastFailure
|
||||
return untrusted ?: lastFailure
|
||||
}
|
||||
|
||||
internal fun probe(base: HttpUrl): Outcome {
|
||||
@@ -195,7 +231,7 @@ class CalDavDiscovery(
|
||||
// 401 is not a failure — iCloud and Zoho answer it from
|
||||
// /.well-known/caldav, which *is* the DAV root, and it is RFC-legal.
|
||||
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
|
||||
@@ -258,7 +294,7 @@ class CalDavDiscovery(
|
||||
}
|
||||
homeSetResult.exceptionOrNull()?.let { error ->
|
||||
if (isUnauthorized(error)) return Outcome.NeedsAuthentication(listOf(principal.host))
|
||||
return Outcome.Failed(causeOf(error), error.message ?: error.toString())
|
||||
return failure(error)
|
||||
}
|
||||
|
||||
if (homeSets.isEmpty()) {
|
||||
@@ -299,6 +335,7 @@ class CalDavDiscovery(
|
||||
url = homeSet,
|
||||
reason = it.message ?: it.toString(),
|
||||
needsAuthentication = isUnauthorized(it),
|
||||
certificate = untrustedCertificateIn(it)?.let(ServerCertificate::of),
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -309,8 +346,11 @@ class CalDavDiscovery(
|
||||
// lists" for what is actually an error.
|
||||
if (!anySucceeded) {
|
||||
val needAuth = failures.filter { it.needsAuthentication }
|
||||
val certificate = failures.firstNotNullOfOrNull { it.certificate }
|
||||
return if (needAuth.isNotEmpty() && needAuth.size == failures.size) {
|
||||
Outcome.NeedsAuthentication(needAuth.map { it.url.host }.distinct())
|
||||
} else if (certificate != null) {
|
||||
Outcome.UntrustedCertificate(certificate)
|
||||
} else {
|
||||
Outcome.Failed(
|
||||
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 =
|
||||
error is at.bitfire.dav4jvm.exception.UnauthorizedException
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.jeanlucmakiola.caldav
|
||||
|
||||
import at.bitfire.dav4jvm.BasicDigestAuthHandler
|
||||
import okhttp3.ConnectionSpec
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
@@ -9,6 +10,8 @@ import okhttp3.ResponseBody.Companion.asResponseBody
|
||||
import okio.GzipSource
|
||||
import okio.buffer
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
|
||||
/**
|
||||
* The HTTP clients the CalDAV layer talks through.
|
||||
@@ -45,7 +48,8 @@ object CalDavHttp {
|
||||
}
|
||||
|
||||
/** 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.
|
||||
@@ -75,6 +79,7 @@ object CalDavHttp {
|
||||
username: String,
|
||||
password: String,
|
||||
origin: HttpUrl,
|
||||
trust: ServerTrust = ServerTrust.DEFAULT,
|
||||
): OkHttpClient {
|
||||
val handler = BasicDigestAuthHandler(
|
||||
// ⚠️ The **registrable** domain, not the host, and it must be
|
||||
@@ -97,16 +102,34 @@ object CalDavHttp {
|
||||
domain = origin.topPrivateDomain() ?: origin.host,
|
||||
username = username,
|
||||
password = password,
|
||||
// Never over cleartext, challenged or not.
|
||||
insecureBasic = false,
|
||||
// Never over cleartext, challenged or not — unless the user opted
|
||||
// this account into plain http, where nothing else could work.
|
||||
insecureBasic = trust.allowCleartext,
|
||||
)
|
||||
return base(userAgent)
|
||||
return base(userAgent, trust)
|
||||
.authenticator(handler)
|
||||
.addNetworkInterceptor(handler)
|
||||
.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 { chain ->
|
||||
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
|
||||
* 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 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.
|
||||
@@ -149,16 +155,15 @@ object ServiceDiscovery {
|
||||
// explicit, which is the whole point of Posteo's 8443.
|
||||
val port = if (record.port == 443) "" else ":${record.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) {
|
||||
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, "/", "$label + root")
|
||||
add(candidates, origin, WELL_KNOWN, "$label + .well-known", guessed)
|
||||
add(candidates, origin, "/", "$label + root", guessed)
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
@@ -181,9 +186,15 @@ object ServiceDiscovery {
|
||||
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
|
||||
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
|
||||
|
||||
@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/"))
|
||||
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`() {
|
||||
val client = CalDavHttp.anonymous("Agendula test")
|
||||
val client = CalDavHttp.anonymous("Agendula test", ServerTrust(allowCleartext = true))
|
||||
server.enqueue(MockResponse().setResponseCode(200))
|
||||
|
||||
client.newCall(
|
||||
@@ -536,7 +536,7 @@ END:VCALENDAR</C:calendar-data>
|
||||
}
|
||||
|
||||
@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()
|
||||
okio.GzipSink(body).buffer().use { it.writeUtf8("BEGIN:VCALENDAR") }
|
||||
server.enqueue(
|
||||
|
||||
@@ -21,7 +21,7 @@ class CollectionAdminTest {
|
||||
|
||||
@Before fun setUp() {
|
||||
server = MockWebServer().apply { start() }
|
||||
admin = DavCollectionAdmin(CalDavHttp.anonymous("test"))
|
||||
admin = DavCollectionAdmin(CalDavHttp.anonymous("test", ServerTrust(allowCleartext = true)))
|
||||
}
|
||||
|
||||
@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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
### جديد
|
||||
- يمكن أن يُحسب التذكير من بدء المهمة أو من موعد الاستحقاق.
|
||||
- يمكن لمهام اليوم الكامل أن تحمل تذكيرات في أوقات محددة.
|
||||
- يمكن للتطبيقات الأخرى قراءة مهامك المحلية وتعديلها عبر OpenTasks، بإذنك.
|
||||
|
||||
### تغييرات
|
||||
- تُزامَن التذكيرات كـ VALARM، فترنّ أيضًا في تذكيرات Apple وThunderbird وtasks.org.
|
||||
|
||||
### إصلاحات
|
||||
- تعمل الأدوات مجددًا بعد إنجاز المهام.
|
||||
- عاد تسجيل الدخول بالبريد الإلكتروني يعمل لدى mailbox.org وPosteo.
|
||||
@@ -0,0 +1,11 @@
|
||||
Agendula تطبيق مهام حديث ومفتوح المصدر لنظام Android.
|
||||
|
||||
زامن مهامك عبر CalDAV: أضف حساب Nextcloud أو Radicale أو Baïkal أو أي حساب CalDAV آخر، واختر قوائم المهام التي تريد مزامنتها. أو احتفظ بكل شيء على الهاتف، فـ Agendula يخزّن مهامك بنفسه، دون حساب ودون تثبيت أي شيء آخر.
|
||||
|
||||
هل تستخدم OpenTasks أو tasks.org؟ اربط Agendula بذلك التطبيق لتظهر المهام التي يزامنها DAVx5 أو ما يشبهه كما هي، وانسخها إلى تخزين Agendula الخاص متى شئت من الإعدادات ← التخزين.
|
||||
|
||||
ما يقدمه: قوائم تديرها داخل التطبيق، تواريخ الاستحقاق والبدء، مهام اليوم الكامل، مهام فرعية، أولويات، نسبة الإنجاز، مهام متكررة يمكنك تعديل موعد واحد منها، عدة تذكيرات لكل مهمة مع الغفوة، استيراد iCalendar وتصديره، أداة للشاشة الرئيسية، اختصارات المشغّل، وبلاطة في الإعدادات السريعة.
|
||||
|
||||
التصميم Material 3 Expressive حقيقي في كل مكان، مع ألوان ديناميكية وحركة وأشكال معبّرة.
|
||||
|
||||
الخصوصية: لا قياس عن بُعد، ولا تحليلات، ولا إعلانات. لا يتصل Agendula بالإنترنت إلا للمزامنة مع حسابات CalDAV التي تضيفها. ودون حساب، لا تغادر مهامك الجهاز إلا إذا صدّرتها.
|
||||
@@ -0,0 +1 @@
|
||||
تطبيق مهام بتصميم Material 3 Expressive مع مزامنة CalDAV.
|
||||
@@ -0,0 +1 @@
|
||||
Agendula: Tasks
|
||||
@@ -0,0 +1,11 @@
|
||||
### Nové
|
||||
- Připomínky se mohou počítat od začátku úkolu i od termínu.
|
||||
- Celodenní úkoly mohou mít připomínky v pevný čas.
|
||||
- Jiné aplikace mohou s vaším svolením číst a upravovat místní úkoly přes OpenTasks.
|
||||
|
||||
### Změny
|
||||
- Připomínky se synchronizují jako VALARM a zvoní i v Připomínkách Apple, Thunderbirdu a tasks.org.
|
||||
|
||||
### Opravy
|
||||
- Widgety opět fungují po odškrtnutí úkolu.
|
||||
- Přihlášení e-mailem u mailbox.org a Posteo opět funguje.
|
||||
@@ -0,0 +1,11 @@
|
||||
Agendula je moderní open-source aplikace na úkoly pro Android.
|
||||
|
||||
Synchronizujte úkoly přes CalDAV: přidejte účet Nextcloud, Radicale, Baïkal nebo jiný CalDAV účet a vyberte, které seznamy úkolů se mají synchronizovat. Nebo nechte vše v telefonu. Agendula si úkoly ukládá sama, bez účtu a bez instalace čehokoli dalšího.
|
||||
|
||||
Už používáte OpenTasks nebo tasks.org? Nasměrujte Agendulu na tuto aplikaci, aby se úkoly synchronizované přes DAVx5 a podobně zobrazily tak, jak jsou, a kdykoli si je zkopírujte do vlastního úložiště Agenduly v Nastavení → Úložiště.
|
||||
|
||||
Co umí: seznamy spravované přímo v aplikaci, termíny a data začátku, celodenní úkoly, podúkoly, priority, průběh, opakované úkoly s možností upravit jediný výskyt, více připomínek na úkol s odložením, import a export iCalendar, widget na plochu, zkratky spouštěče a dlaždici Rychlého nastavení.
|
||||
|
||||
Design je skrz naskrz Material 3 Expressive, s dynamickými barvami a expresivními animacemi a tvary.
|
||||
|
||||
Soukromí: žádná telemetrie, žádná analytika, žádné reklamy. Agendula se připojuje k internetu jen kvůli synchronizaci s CalDAV účty, které sami přidáte. Bez účtu vaše úkoly zařízení neopustí, pokud je sami neexportujete.
|
||||
@@ -0,0 +1 @@
|
||||
Úkolníček v Material 3 Expressive se synchronizací CalDAV.
|
||||
@@ -0,0 +1 @@
|
||||
Agendula: Tasks
|
||||
@@ -0,0 +1,11 @@
|
||||
### Neu
|
||||
- Erinnerungen können ab dem Start oder ab der Fälligkeit zählen.
|
||||
- Ganztägige Aufgaben können Erinnerungen zu festen Uhrzeiten haben.
|
||||
- Andere Apps können lokale Aufgaben über OpenTasks lesen und bearbeiten, wenn du es erlaubst.
|
||||
|
||||
### Geändert
|
||||
- Erinnerungen werden als VALARM synchronisiert und klingeln auch in Apple Erinnerungen, Thunderbird und tasks.org.
|
||||
|
||||
### Behoben
|
||||
- Widgets funktionieren wieder nach dem Abhaken.
|
||||
- Die Anmeldung per E-Mail klappt wieder bei mailbox.org und Posteo.
|
||||
@@ -0,0 +1,11 @@
|
||||
### Added
|
||||
- Reminders can count from a task's start as well as its due date.
|
||||
- All-day tasks can have reminders at set times, e.g. 09:00 and 17:00.
|
||||
- Other apps can read and edit your local tasks through an OpenTasks provider, with your permission.
|
||||
|
||||
### Changed
|
||||
- Reminders sync as VALARMs, so they also ring in Apple Reminders, Thunderbird and tasks.org.
|
||||
|
||||
### Fixed
|
||||
- Widgets work again after ticking off tasks.
|
||||
- Signing in by email works again for mailbox.org and Posteo.
|
||||
@@ -0,0 +1,11 @@
|
||||
### Added
|
||||
- Reminders can count from a task's start as well as its due date.
|
||||
- All-day tasks can have reminders at set times, e.g. 09:00 and 17:00.
|
||||
- Other apps can read and edit your local tasks through an OpenTasks provider, with your permission.
|
||||
|
||||
### Changed
|
||||
- Reminders sync as VALARMs, so they also ring in Apple Reminders, Thunderbird and tasks.org.
|
||||
|
||||
### Fixed
|
||||
- Widgets work again after ticking off tasks.
|
||||
- Signing in by email works again for mailbox.org and Posteo.
|
||||
@@ -0,0 +1,11 @@
|
||||
### Nouveautés
|
||||
- Un rappel peut partir du début ou de l'échéance d'une tâche.
|
||||
- Rappels à heure fixe pour les tâches sur la journée.
|
||||
- Avec votre accord, d'autres applis peuvent lire et modifier vos tâches locales via OpenTasks.
|
||||
|
||||
### Modifications
|
||||
- Les rappels se synchronisent en VALARM et sonnent aussi dans Rappels d'Apple, Thunderbird et tasks.org.
|
||||
|
||||
### Corrections
|
||||
- Les widgets marchent de nouveau après une tâche cochée.
|
||||
- La connexion par e-mail marche de nouveau avec mailbox.org et Posteo.
|
||||
@@ -0,0 +1,11 @@
|
||||
Agendula est une appli de tâches moderne et open source pour Android.
|
||||
|
||||
Synchronisez vos tâches via CalDAV : ajoutez un compte Nextcloud, Radicale, Baïkal ou tout autre compte CalDAV et choisissez les listes de tâches à synchroniser. Ou gardez tout sur le téléphone : Agendula stocke vos tâches lui-même, sans compte et sans rien d'autre à installer.
|
||||
|
||||
Vous utilisez déjà OpenTasks ou tasks.org ? Reliez Agendula à cette appli pour que les tâches synchronisées par DAVx5 ou similaire s'affichent telles quelles, et copiez-les quand vous voulez dans le stockage propre d'Agendula, sous Réglages → Stockage.
|
||||
|
||||
Ce qu'il fait : listes gérées dans l'appli, dates d'échéance et de début, tâches sur toute la journée, sous-tâches, priorités, avancement, tâches récurrentes dont vous pouvez modifier une seule occurrence, plusieurs rappels par tâche avec report, import et export iCalendar, un widget d'écran d'accueil, des raccourcis de lanceur et une tuile de Réglages rapides.
|
||||
|
||||
Le design est du vrai Material 3 Expressive de bout en bout, avec couleurs dynamiques, animations et formes expressives.
|
||||
|
||||
Confidentialité : pas de télémétrie, pas d'analytique, pas de publicité. Agendula ne se connecte que pour synchroniser les comptes CalDAV que vous ajoutez. Sans compte, vos tâches ne quittent l'appareil que si vous les exportez.
|
||||
@@ -0,0 +1 @@
|
||||
Une appli de tâches Material 3 Expressive avec synchro CalDAV.
|
||||
@@ -0,0 +1 @@
|
||||
Agendula: Tasks
|
||||
@@ -0,0 +1,11 @@
|
||||
### Novidades
|
||||
- Lembretes podem contar a partir do início da tarefa ou do prazo.
|
||||
- Tarefas de dia inteiro podem ter lembretes em horários fixos.
|
||||
- Outros apps podem ler e editar suas tarefas locais via OpenTasks, com sua permissão.
|
||||
|
||||
### Alterações
|
||||
- Lembretes sincronizam como VALARM e tocam também no Lembretes da Apple, Thunderbird e tasks.org.
|
||||
|
||||
### Correções
|
||||
- Widgets voltam a funcionar após marcar tarefas.
|
||||
- O login por e-mail volta a funcionar no mailbox.org e Posteo.
|
||||
+1
-1
Submodule floret-kit updated: e8d33329c8...a26bea7871
Reference in New Issue
Block a user