From 7bcac70cec693652ddd3952ba0bddb733d07acb7 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 2 Nov 2026 21:26:15 +0100 Subject: [PATCH] Release 1.3.0 (#134) Stable 1.3.0, after 1.3.0-beta.1 and beta.2. - Version bumped to 1.3.0 (1030099); the CHANGELOG entries moved from Unreleased to 1.3.0. - What's New for every shipped locale, now including ru-RU, which also gets its full store listing. values-uk and values-zh-rCN came in empty from Weblate, so they get no store locale yet. - fr, ar, cs and ru added to locales_config.xml, so they show up in the in-app language picker and Android's per-app language settings. Before, only en, de and pt-BR did. - `main` (Weblate #87) merged in, no conflicts and no stale keys. - Lint has failed on this branch since #113: `NavController.currentBackStack` is a RestrictedApi. It's suppressed at that one call, since no public API exposes the whole back stack and `visibleEntries` drops an entry once another one covers it. #119, #121, #122/#123 and #127/#129 landed after beta.2, so no tester has seen them yet. Closes #68, closes #73, closes #90, closes #91, closes #93, closes #94, closes #99, closes #104, closes #105, closes #119, closes #121, closes #122, closes #123, closes #127, closes #129 Co-authored-by: Jean-Luc Makiola Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/134 --- CHANGELOG.md | 34 ++ app/build.gradle.kts | 4 +- .../4.json | 553 ++++++++++++++++++ .../tasks/room/TasksDatabaseMigrationTest.kt | 15 + .../agendula/data/di/SyncModule.kt | 9 +- .../agendula/data/sync/AccountRepository.kt | 60 +- .../agendula/data/sync/AccountSyncRequests.kt | 20 + .../agendula/data/sync/CalDavGateway.kt | 52 +- .../data/sync/PendingLoginFlowStore.kt | 16 +- .../agendula/data/sync/PullSync.kt | 76 +++ .../data/sync/RemoteListRepository.kt | 8 +- .../agendula/data/sync/ServerTrustKeys.kt | 44 ++ .../agendula/data/sync/ServerTrustStore.kt | 38 ++ .../agendula/data/sync/SyncEngine.kt | 3 +- .../agendula/data/sync/SyncFailure.kt | 1 + .../agendula/data/sync/SyncTrigger.kt | 6 +- .../agendula/data/sync/SyncWorker.kt | 4 +- .../agendula/data/sync/push/PushRegistrar.kt | 4 +- .../ui/accounts/AccountDetailScreen.kt | 89 ++- .../agendula/ui/accounts/AccountIdentity.kt | 10 +- .../agendula/ui/accounts/AccountsScreen.kt | 2 +- .../agendula/ui/accounts/AccountsViewModel.kt | 24 +- .../ui/accounts/add/AddAccountScreen.kt | 44 +- .../ui/accounts/add/AddAccountText.kt | 1 + .../ui/accounts/add/AddAccountViewModel.kt | 280 +++++++-- .../ui/accounts/add/ServerTrustSteps.kt | 76 +++ .../full/res/xml/network_security_config.xml | 15 +- app/src/main/AndroidManifest.xml | 32 +- .../de/jeanlucmakiola/agendula/AgendulaApp.kt | 14 +- .../jeanlucmakiola/agendula/MainActivity.kt | 55 +- .../data/appname/LauncherNameManager.kt | 86 +++ .../agendula/data/di/DataModule.kt | 2 +- .../agendula/data/prefs/SettingsPrefs.kt | 27 +- .../data/reminders/DueReminderReceiver.kt | 59 +- .../data/reminders/ReminderActionReceiver.kt | 5 +- .../agendula/data/reminders/ReminderDiff.kt | 4 +- .../data/reminders/ReminderRefresher.kt | 35 ++ .../data/reminders/ReminderScheduler.kt | 40 +- .../agendula/data/reminders/TaskNotifier.kt | 39 +- .../agendula/data/sync/ManualSync.kt | 23 + .../agendula/data/sync/SyncRequests.kt | 2 +- .../data/tasks/ModeRoutingTasksDataSource.kt | 12 +- .../data/tasks/StoreChangeDebouncer.kt | 39 ++ .../agendula/data/tasks/StoredReminders.kt | 48 +- .../agendula/data/tasks/TasksDataSource.kt | 3 + .../data/tasks/provider/ProviderWriter.kt | 10 +- .../agendula/data/tasks/room/AccountDao.kt | 3 + .../agendula/data/tasks/room/Entities.kt | 2 + .../agendula/data/tasks/room/Projections.kt | 2 + .../data/tasks/room/RoomTaskMapper.kt | 4 +- .../data/tasks/room/RoomTasksDataSource.kt | 9 +- .../agendula/data/tasks/room/TaskDao.kt | 6 +- .../agendula/data/tasks/room/TaskListDao.kt | 4 +- .../agendula/data/tasks/room/TasksDatabase.kt | 9 +- .../agendula/domain/SavedFilter.kt | 6 +- .../agendula/domain/TaskFilter.kt | 23 +- .../agendula/domain/TaskSections.kt | 11 +- .../jeanlucmakiola/agendula/ui/RootScreen.kt | 4 + .../agendula/ui/common/EdgeSwipeGuard.kt | 66 +++ .../agendula/ui/common/SyncPullBox.kt | 51 ++ .../agendula/ui/edit/TaskEditViewModel.kt | 20 +- .../agendula/ui/imports/ImportViewModel.kt | 2 +- .../agendula/ui/lists/ListsScreen.kt | 10 +- .../agendula/ui/lists/ListsViewModel.kt | 21 +- .../agendula/ui/navigation/AgendulaNavHost.kt | 30 +- .../agendula/ui/navigation/Destinations.kt | 9 +- .../agendula/ui/navigation/NavRequest.kt | 22 +- .../agendula/ui/settings/AppearanceScreen.kt | 49 ++ .../agendula/ui/settings/SettingsViewModel.kt | 25 +- .../agendula/ui/tasklist/TaskListScreen.kt | 74 ++- .../agendula/ui/tasklist/TaskListViewModel.kt | 17 + .../agendula/ui/theme/SystemTheme.kt | 44 ++ .../jeanlucmakiola/agendula/ui/theme/Theme.kt | 2 + .../agendula/widget/TaskWidget.kt | 11 +- .../widget/TaskWidgetConfigActivity.kt | 13 +- .../agendula/widget/TaskWidgetUpdater.kt | 19 +- .../agendula/widget/TodayWidget.kt | 2 +- .../agendula/widget/UpNextWidget.kt | 2 +- .../agendula/widget/WidgetData.kt | 8 +- .../agendula/widget/WidgetStats.kt | 48 +- app/src/main/res/values-night-v31/colors.xml | 4 + app/src/main/res/values-night-v34/colors.xml | 3 + app/src/main/res/values-night/bools.xml | 3 + app/src/main/res/values-night/colors.xml | 3 + app/src/main/res/values-v31/colors.xml | 4 + app/src/main/res/values-v34/colors.xml | 3 + app/src/main/res/values/bools.xml | 3 + app/src/main/res/values/colors.xml | 4 + app/src/main/res/values/strings.xml | 26 + app/src/main/res/values/themes.xml | 16 +- app/src/main/res/xml/locales_config.xml | 4 + .../agendula/data/di/SyncModule.kt | 4 + .../data/appname/LauncherNameManagerTest.kt | 31 + .../data/reminders/ExpandedTextTest.kt | 22 + .../data/reminders/OccurrenceLookupTest.kt | 44 ++ .../data/reminders/ReminderDiffTest.kt | 8 + .../data/reminders/ReminderIsStaleTest.kt | 54 ++ .../data/tasks/StoredRemindersTest.kt | 71 +++ .../data/tasks/room/RoomTaskMapperTest.kt | 12 + .../agendula/domain/TaskFilteringTest.kt | 27 +- .../agendula/domain/TaskSectionsTest.kt | 8 + .../agendula/ui/theme/SystemThemeTest.kt | 26 + .../agendula/ui/theme/WindowColorsTest.kt | 27 + .../agendula/widget/WidgetDataTest.kt | 8 + .../agendula/widget/WidgetStatsTest.kt | 31 + .../data/sync/HttpClientConstructionTest.kt | 41 ++ .../data/sync/ServerTrustStoreTest.kt | 57 ++ .../accounts/add/AddAccountViewModelTest.kt | 271 ++++++++- caldav/build.gradle.kts | 1 + .../jeanlucmakiola/caldav/CalDavDiscovery.kt | 60 +- .../de/jeanlucmakiola/caldav/CalDavHttp.kt | 59 +- .../caldav/PinningTrustManager.kt | 102 ++++ .../de/jeanlucmakiola/caldav/ServerTrust.kt | 85 +++ .../jeanlucmakiola/caldav/ServiceDiscovery.kt | 31 +- .../caldav/CalendarCollectionTest.kt | 6 +- .../caldav/CollectionAdminTest.kt | 2 +- .../jeanlucmakiola/caldav/ServerTrustTest.kt | 211 +++++++ .../android/ar/changelogs/1030099.txt | 13 + .../android/cs-CZ/changelogs/1030099.txt | 13 + .../android/de-DE/changelogs/1030099.txt | 13 + .../android/en-GB/changelogs/1030099.txt | 13 + .../android/en-US/changelogs/1030099.txt | 13 + .../android/fr-FR/changelogs/1030099.txt | 13 + .../android/pt-BR/changelogs/1030099.txt | 13 + .../android/ru-RU/changelogs/1030099.txt | 13 + .../android/ru-RU/full_description.txt | 11 + .../android/ru-RU/short_description.txt | 1 + fastlane/metadata/android/ru-RU/title.txt | 1 + floret-kit | 2 +- 129 files changed, 3799 insertions(+), 343 deletions(-) create mode 100644 app/schemas/de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase/4.json create mode 100644 app/src/full/java/de/jeanlucmakiola/agendula/data/sync/AccountSyncRequests.kt create mode 100644 app/src/full/java/de/jeanlucmakiola/agendula/data/sync/PullSync.kt create mode 100644 app/src/full/java/de/jeanlucmakiola/agendula/data/sync/ServerTrustKeys.kt create mode 100644 app/src/full/java/de/jeanlucmakiola/agendula/data/sync/ServerTrustStore.kt create mode 100644 app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/ServerTrustSteps.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/appname/LauncherNameManager.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderRefresher.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/sync/ManualSync.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StoreChangeDebouncer.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/ui/common/EdgeSwipeGuard.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/ui/common/SyncPullBox.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/ui/theme/SystemTheme.kt create mode 100644 app/src/main/res/values-night-v31/colors.xml create mode 100644 app/src/main/res/values-night-v34/colors.xml create mode 100644 app/src/main/res/values-night/bools.xml create mode 100644 app/src/main/res/values-night/colors.xml create mode 100644 app/src/main/res/values-v31/colors.xml create mode 100644 app/src/main/res/values-v34/colors.xml create mode 100644 app/src/main/res/values/bools.xml create mode 100644 app/src/test/java/de/jeanlucmakiola/agendula/data/appname/LauncherNameManagerTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/agendula/data/reminders/ExpandedTextTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/agendula/data/reminders/OccurrenceLookupTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/agendula/data/reminders/ReminderIsStaleTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/agendula/ui/theme/SystemThemeTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/agendula/ui/theme/WindowColorsTest.kt create mode 100644 app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/HttpClientConstructionTest.kt create mode 100644 app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/ServerTrustStoreTest.kt create mode 100644 caldav/src/main/kotlin/de/jeanlucmakiola/caldav/PinningTrustManager.kt create mode 100644 caldav/src/main/kotlin/de/jeanlucmakiola/caldav/ServerTrust.kt create mode 100644 caldav/src/test/kotlin/de/jeanlucmakiola/caldav/ServerTrustTest.kt create mode 100644 fastlane/metadata/android/ar/changelogs/1030099.txt create mode 100644 fastlane/metadata/android/cs-CZ/changelogs/1030099.txt create mode 100644 fastlane/metadata/android/de-DE/changelogs/1030099.txt create mode 100644 fastlane/metadata/android/en-GB/changelogs/1030099.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/1030099.txt create mode 100644 fastlane/metadata/android/fr-FR/changelogs/1030099.txt create mode 100644 fastlane/metadata/android/pt-BR/changelogs/1030099.txt create mode 100644 fastlane/metadata/android/ru-RU/changelogs/1030099.txt create mode 100644 fastlane/metadata/android/ru-RU/full_description.txt create mode 100644 fastlane/metadata/android/ru-RU/short_description.txt create mode 100644 fastlane/metadata/android/ru-RU/title.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index c79ac86..9a65170 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,40 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +## [1.3.0] - 2026-11-02 + +### Added +- Self-hosted servers with a self-signed certificate can be added: Agendula + shows the certificate's fingerprint and asks whether to trust it. A server + reached over plain http can be added too, after a warning. If a trusted + certificate is later renewed, "Sign in again" on the account asks about the + new one (#68). +- An account can be given its own name (#105). +- Pull down on the task list to sync (#73). +- Task list sections can be collapsed (#91). +- A pure black theme for AMOLED screens (#99). +- Agendula can show up as "Tasks" in your launcher, picked from launcher + previews as in Calendula (#104). +- Reminder notifications show the task's description (#94). + +### Changed +- Adding or opening a task from a widget returns you to the home screen + afterwards (#93). +- A swipe that starts at the screen edge no longer swipes the task (#90). +- Today also lists tasks that start today, and keeps the ones completed today + in a Completed section (#123). + +### Fixed +- A task created from Today, or from a Today widget, is due today, so it shows + up there (#122). +- Completing a task from the list, a widget or another device now cancels its + upcoming reminder and clears one already showing (#127, #129). +- The splash screen follows the app's theme, including Black and dynamic + colour, instead of flashing white in dark mode (#119). +- A task saved without its default start or due reminder because it had + already rung (a start of "now", say) gets it once that date is moved later, + if the task has no reminder from that date (#121). + ## [1.2.0] - 2026-10-26 ### Added diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0b1c832..ae906e1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -33,8 +33,8 @@ android { // (1.0.x: 1.0.0 -> 10000; from 1.1.0: 1.1.0-beta.1 -> 1010001, // 1.1.0 -> 1010099), and CI fails if the committed one doesn't match. // See docs/RELEASING.md. - versionCode = 1020099 - versionName = "1.2.0" + versionCode = 1030099 + versionName = "1.3.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/schemas/de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase/4.json b/app/schemas/de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase/4.json new file mode 100644 index 0000000..5502a50 --- /dev/null +++ b/app/schemas/de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase/4.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseMigrationTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseMigrationTest.kt index a6fa376..9e37f7e 100644 --- a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseMigrationTest.kt +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseMigrationTest.kt @@ -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" } diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/data/di/SyncModule.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/di/SyncModule.kt index ac27212..d8a6b34 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/data/di/SyncModule.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/di/SyncModule.kt @@ -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 diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/AccountRepository.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/AccountRepository.kt index c77a6d5..64eae5a 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/AccountRepository.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/AccountRepository.kt @@ -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, /** 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> = 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> = database.taskLists().observeSynced() @@ -115,6 +128,7 @@ class AccountRepository @Inject constructor( found: CalDavDiscovery.Outcome.Found, selected: Set, 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, + 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) = database.runInTransaction { - inserted.forEach { database.taskLists().delete(it) } - database.accounts().delete(accountId) + private suspend fun rollback(accountId: Long, inserted: List) { + 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 diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/AccountSyncRequests.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/AccountSyncRequests.kt new file mode 100644 index 0000000..9f29c61 --- /dev/null +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/AccountSyncRequests.kt @@ -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) + } +} diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/CalDavGateway.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/CalDavGateway.kt index 46ac615..d556a58 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/CalDavGateway.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/CalDavGateway.kt @@ -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) } diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/PendingLoginFlowStore.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/PendingLoginFlowStore.kt index 2f0280d..8befbb8 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/PendingLoginFlowStore.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/PendingLoginFlowStore.kt @@ -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") } } diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/PullSync.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/PullSync.kt new file mode 100644 index 0000000..3e1f1fc --- /dev/null +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/PullSync.kt @@ -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 = + combine(availability.observe(), accounts.observeAll()) { usable, all -> usable && all.isNotEmpty() } + + private val _syncing = MutableStateFlow(false) + override val syncing: StateFlow = _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) { + 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 + } +} diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/RemoteListRepository.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/RemoteListRepository.kt index 3fb8f29..7441ea1 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/RemoteListRepository.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/RemoteListRepository.kt @@ -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) diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/ServerTrustKeys.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/ServerTrustKeys.kt new file mode 100644 index 0000000..1a574a7 --- /dev/null +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/ServerTrustKeys.kt @@ -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>(certificatesName).orEmpty().toSet(), + allowCleartext = handle[cleartextName] ?: false, + ) + + fun write(handle: SavedStateHandle, trust: ServerTrust) { + handle[certificatesName] = ArrayList(trust.pinnedCertificates) + handle[cleartextName] = trust.allowCleartext + } +} diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/ServerTrustStore.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/ServerTrustStore.kt new file mode 100644 index 0000000..364d759 --- /dev/null +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/ServerTrustStore.kt @@ -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, +) { + + suspend fun get(accountId: Long): ServerTrust = read(dataStore.data.first(), accountId) + + fun observe(accountId: Long): Flow = + 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") +} diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncEngine.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncEngine.kt index 7b16605..8a0bd2f 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncEngine.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncEngine.kt @@ -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()]) diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncFailure.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncFailure.kt index b114d32..14b1da0 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncFailure.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncFailure.kt @@ -49,6 +49,7 @@ data class SyncFailure(val kind: Kind, val httpCode: Int? = null) { private val TLS = listOf( "SSLHandshakeException", "SSLPeerUnverifiedException", + "UntrustedHostException", "CertPathValidatorException", "CertificateException", "SSLException", diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncTrigger.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncTrigger.kt index 9818c7b..fc796f2 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncTrigger.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncTrigger.kt @@ -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. diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncWorker.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncWorker.kt index 9a1cedc..897577d 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncWorker.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncWorker.kt @@ -56,8 +56,8 @@ class SyncWorker @AssistedInject constructor( // log line is the same as saying nothing. Only what is new: the // store has already dropped whatever the user has been told. noticeNotifier.post(accountName, outcome.notices) - // Nothing else re-arms reminders for what a sync pulled in: in our - // own store no provider broadcast fires. + // Here rather than left to ReminderRefresher's debounce: once + // the worker returns, nothing keeps the process alive for it. if (outcome.reports.any { it.hadWork || it.discardedEdits.isNotEmpty() }) { runCatching { reminderScheduler.sync() } } diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/push/PushRegistrar.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/push/PushRegistrar.kt index b3faa64..c1eccc8 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/push/PushRegistrar.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/push/PushRegistrar.kt @@ -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() diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountDetailScreen.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountDetailScreen.kt index 1a88396..36e815a 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountDetailScreen.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountDetailScreen.kt @@ -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 diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountIdentity.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountIdentity.kt index 6506346..58ea126 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountIdentity.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountIdentity.kt @@ -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() }, ) diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsScreen.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsScreen.kt index a4467d5..f6567a5 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsScreen.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsScreen.kt @@ -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), diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsViewModel.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsViewModel.kt index 4a4e062..6455e85 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsViewModel.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsViewModel.kt @@ -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 = 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.observe(accountId) + /** The user has read what the last sync changed. */ fun dismissNotices(account: AccountEntity) { viewModelScope.launch { notices.dismiss(account.id) } diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountScreen.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountScreen.kt index 7b3c390..5dfc4ae 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountScreen.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountScreen.kt @@ -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 diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountText.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountText.kt index 48d83c8..ecabd75 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountText.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountText.kt @@ -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 diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModel.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModel.kt index ef9e76b..95bff6d 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModel.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModel.kt @@ -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, val back: AddAccountStep) + + private var trustPrompt: TrustPrompt? = null + /** Which hosts asked for credentials, for the cross-domain diagnostic. */ private var hostsNeedingAuth: List get() = handle.get>(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, ) /** diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/ServerTrustSteps.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/ServerTrustSteps.kt new file mode 100644 index 0000000..f65fc92 --- /dev/null +++ b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/ServerTrustSteps.kt @@ -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)) +} diff --git a/app/src/full/res/xml/network_security_config.xml b/app/src/full/res/xml/network_security_config.xml index f8684a7..4e0774f 100644 --- a/app/src/full/res/xml/network_security_config.xml +++ b/app/src/full/res/xml/network_security_config.xml @@ -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. --> - + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0276b0b..4290d6e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -65,10 +65,8 @@ android:exported="true" android:launchMode="singleTop" android:windowSoftInputMode="adjustResize"> - - - - + @@ -89,6 +87,32 @@ + + + + + + + + + + + + stored transition is past, which the launch sync below covers. + // A switch rebuilds the alarms through ReminderRefresher, which hears it + // as a store change. Channels only once startup's own null -> stored + // transition is past, which the launch sync below covers. val started = AtomicBoolean(false) entryPoint.providerResolver().onModeChanged { - if (started.get()) { - scope.launch { runCatching { scheduler.sync() } } - runCatching { entryPoint.taskNotifier().refreshChannel() } - } + if (started.get()) runCatching { entryPoint.taskNotifier().refreshChannel() } } startupGate.start() refreshNotificationChannels() @@ -88,6 +86,7 @@ class AgendulaApp : Application(), Configuration.Provider { scheduler.sync() } runCatching { entryPoint.taskWidgetUpdater().start() } + runCatching { entryPoint.reminderRefresher().start() } runCatching { entryPoint.providerSnapshotSource().start() } runCatching { ReminderMaintenanceWorker.schedule(this@AgendulaApp) } } @@ -124,6 +123,7 @@ class AgendulaApp : Application(), Configuration.Provider { fun taskNotifier(): TaskNotifier fun channelRefreshers(): Set<@JvmSuppressWildcards ChannelRefresher> fun taskWidgetUpdater(): TaskWidgetUpdater + fun reminderRefresher(): ReminderRefresher fun providerSnapshotSource(): ProviderSnapshotSource } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/agendula/MainActivity.kt index 1ac4cd6..0a0abd0 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/MainActivity.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.core.net.toUri +import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.prefs.is24Hour import de.jeanlucmakiola.agendula.ui.common.LocalFirstDayOfWeek import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat @@ -28,15 +29,17 @@ import de.jeanlucmakiola.agendula.data.di.LaunchHook import de.jeanlucmakiola.agendula.domain.SmartList import de.jeanlucmakiola.agendula.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 import de.jeanlucmakiola.agendula.ui.settings.SettingsViewModel import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme +import de.jeanlucmakiola.agendula.ui.theme.SystemTheme import de.jeanlucmakiola.floret.crash.CrashReportDialog import de.jeanlucmakiola.floret.crash.CrashReporter import de.jeanlucmakiola.floret.crash.submitCrashReport +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import javax.inject.Inject @@ -50,6 +53,8 @@ class MainActivity : ComponentActivity() { @Inject lateinit var launchHooks: Set<@JvmSuppressWildcards LaunchHook> + @Inject lateinit var settingsPrefs: SettingsPrefs + // A captured crash report awaiting the user's decision, surfaced as a dialog // over the app on the next launch (the single-crash path). A startup // crash-loop is handled out of band, before setContent — see below. @@ -73,6 +78,13 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() AppShortcuts.publish(this) + // The stored setting, never the view model's placeholder: a night mode + // that flips back and forth would recreate the activity each time. + lifecycleScope.launch { + settingsPrefs.settings.map { it.themeMode to it.dynamicColor }.distinctUntilChanged() + .collect { (mode, dynamicColor) -> SystemTheme.apply(this@MainActivity, mode, dynamicColor) } + } + // Only on a fresh launch: after a rotation or process restore the intent // is the same old one and the back stack already holds its destination. val fromHistory = intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0 @@ -88,15 +100,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 +117,7 @@ class MainActivity : ComponentActivity() { modifier = Modifier.fillMaxSize(), navRequest = navRequest, onNavRequestConsumed = { navRequest = null }, + onReturnHome = ::finish, ) pendingCrashReport?.let { report -> CrashReportDialog( @@ -153,7 +166,9 @@ 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 EXTRA_DUE_TODAY = "de.jeanlucmakiola.agendula.extra.DUE_TODAY" private const val SHARED_TITLE_LIMIT = 500 private const val NO_OCCURRENCE = -1L @@ -161,10 +176,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) } @@ -176,13 +197,17 @@ class MainActivity : ComponentActivity() { /** * The launcher shortcut's, Quick Settings tile's and a widget's "New - * task". [listId] preset the list when it names exactly one; the data - * URI keeps each widget's PendingIntent apart from the others. + * task". [listId] preset the list when it names exactly one, [dueToday] + * the due date for a Today widget; the data URI keeps each widget's + * PendingIntent apart from the others. */ - fun newTaskIntent(context: Context, listId: Long? = null): Intent = - Intent(ACTION_NEW_TASK, listId?.let { "agendula://newtask/$it".toUri() }, context, MainActivity::class.java) + fun newTaskIntent(context: Context, listId: Long? = null, dueToday: Boolean = false): Intent { + val path = listOfNotNull(listId?.toString(), "today".takeIf { dueToday }).joinToString("/") + return Intent(ACTION_NEW_TASK, path.ifEmpty { null }?.let { "agendula://newtask/$it".toUri() }, context, MainActivity::class.java) .putExtra(EXTRA_PRESET_LIST_ID, listId ?: -1L) + .putExtra(EXTRA_DUE_TODAY, dueToday) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } fun todayIntent(context: Context): Intent = Intent(ACTION_TODAY, null, context, MainActivity::class.java) @@ -216,6 +241,8 @@ class MainActivity : ComponentActivity() { when (intent.action) { ACTION_NEW_TASK -> return NavRequest.NewTask( listId = intent.getLongExtra(EXTRA_PRESET_LIST_ID, -1L).takeIf { it > 0L }, + dueToday = intent.getBooleanExtra(EXTRA_DUE_TODAY, false), + returnHome = true, ) ACTION_TODAY -> return NavRequest.OpenSmart(SmartList.TODAY) ACTION_OPEN_SMART -> intent.getStringExtra(EXTRA_SMART_LIST) @@ -230,12 +257,12 @@ class MainActivity : ComponentActivity() { if (intent.action == Intent.ACTION_SEND && intent.type?.startsWith("text/plain") == true) { 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)) } } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/appname/LauncherNameManager.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/appname/LauncherNameManager.kt new file mode 100644 index 0000000..84813b2 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/appname/LauncherNameManager.kt @@ -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 = 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 `` + * 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" + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt index 5d93f08..7190753 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt @@ -89,7 +89,7 @@ object DataProvideModule { // backed-up `.db` does not. The backup rules carry all three files and // the app checkpoints on ON_STOP. .setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING) - .addMigrations(TasksDatabase.MIGRATION_1_2, TasksDatabase.MIGRATION_2_3) + .addMigrations(TasksDatabase.MIGRATION_1_2, TasksDatabase.MIGRATION_2_3, TasksDatabase.MIGRATION_3_4) .build() /** diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt index 15d6244..dd67d3b 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt @@ -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 = 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 = 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 = DEFAULT_COLLAPSED_SECTIONS, ) { /** [tasks] as [filter] shows them: a smart list drops the lists kept out of it. */ fun visibleIn(filter: TaskFilter, tasks: List): List = @@ -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") } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/DueReminderReceiver.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/DueReminderReceiver.kt index 36995e0..7669934 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/DueReminderReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/DueReminderReceiver.kt @@ -5,6 +5,7 @@ import android.content.Context import android.content.Intent import androidx.core.net.toUri import dagger.hilt.android.AndroidEntryPoint +import de.jeanlucmakiola.agendula.data.prefs.Settings import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.tasks.TaskQuery import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource @@ -15,6 +16,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import javax.inject.Inject +import kotlin.time.Instant /** * Fires when a task's reminder alarm goes off. Re-reads the task (it may have @@ -44,8 +46,7 @@ class DueReminderReceiver : BroadcastReceiver() { } val settings = settingsPrefs.settings.first() if (!settings.remindersEnabled) return@launch - val task = runCatching { dataSource.occurrence(taskId, occurrence) }.getOrNull() - if (task != null && !task.isClosed) notifier.postDue(task, settings) + runCatching { notifier.postIfOpen(dataSource, taskId, occurrence, settings) } } finally { pending.finish() } @@ -74,17 +75,51 @@ class DueReminderReceiver : BroadcastReceiver() { /** * The occurrence of [taskId] anchored at [occurrenceStart] (epoch millis), or the * task's current one when there is no anchor or it can no longer be found. - * [TasksDataSource.task] alone resolves a series to whichever occurrence is - * current, which is not necessarily the one a reminder was armed for. + * + * ⚠️ A ticked or edited occurrence is a row of its own with its own id, so + * matching list rows on [taskId] misses it; [TasksDataSource.task] with the + * anchor finds it by start. The list scan is for a provider whose exception + * rows that lookup does not reach. */ internal fun TasksDataSource.occurrence(taskId: Long, occurrenceStart: Long): Task? { - val current = task(taskId) ?: return null - if (occurrenceStart == ScheduledReminder.NO_OCCURRENCE || - current.occurrenceStart?.toEpochMilliseconds() == occurrenceStart - ) { - return current + val anchor = occurrenceStart.takeIf { it != ScheduledReminder.NO_OCCURRENCE }?.let(Instant::fromEpochMilliseconds) + val found = task(taskId, anchor) ?: return null + if (anchor == null || found.occurrenceStart == anchor) return found + return tasks(TaskQuery(listId = found.listId, includeCompleted = true)) + .firstOrNull { (it.taskId == taskId || it.seriesId == taskId) && it.occurrenceStart == anchor } + ?: found +} + +/** + * Whether a reminder posted for [occurrenceStart] is done with, given [current] + * as [occurrence] resolved it. + * + * - No task: gone, but only when [missingMeansGone] — a provider that fails a + * read hands back nothing rather than throwing. + * - [occurrenceStart] null: posted before reminders recorded their occurrence, + * so only a task that does not recur can be judged. + * - A lookup that fell back to another occurrence says nothing about the + * reminded one, so it stays. + */ +internal fun reminderIsStale(occurrenceStart: Long?, current: Task?, missingMeansGone: Boolean): Boolean { + if (current == null) return missingMeansGone + val same = when (occurrenceStart) { + null -> current.occurrenceStart == null + ScheduledReminder.NO_OCCURRENCE -> true + else -> current.occurrenceStart?.toEpochMilliseconds() == occurrenceStart } - return tasks(TaskQuery(listId = current.listId, includeCompleted = true)) - .firstOrNull { it.taskId == taskId && it.occurrenceStart?.toEpochMilliseconds() == occurrenceStart } - ?: current + return same && current.isClosed +} + +/** + * Posts the reminder for [taskId]'s occurrence if it is still open, then checks + * once more: a completion landing between the read and the post may already + * have had its withdrawal pass, which ran before there was anything to withdraw. + */ +internal fun TaskNotifier.postIfOpen(dataSource: TasksDataSource, taskId: Long, occurrenceStart: Long, settings: Settings) { + val task = dataSource.occurrence(taskId, occurrenceStart) ?: return + if (task.isClosed) return + postDue(task, settings) + val now = runCatching { dataSource.occurrence(taskId, occurrenceStart) }.getOrElse { return } + if (now == null || now.isClosed) cancel(taskId) } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderActionReceiver.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderActionReceiver.kt index 368c32f..70134a6 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderActionReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderActionReceiver.kt @@ -32,7 +32,6 @@ class ReminderActionReceiver : BroadcastReceiver() { @Inject lateinit var notifier: TaskNotifier @Inject lateinit var snoozeScheduler: ReminderSnoozeScheduler - @Inject lateinit var reminderScheduler: ReminderScheduler @Inject lateinit var repository: TasksRepository @Inject lateinit var dataSource: TasksDataSource @Inject lateinit var settingsPrefs: SettingsPrefs @@ -54,7 +53,6 @@ class ReminderActionReceiver : BroadcastReceiver() { val anchor = occurrence.takeIf { it != ScheduledReminder.NO_OCCURRENCE } ?.let(Instant::fromEpochMilliseconds) repository.setCompleted(taskId, anchor, completed = true) - reminderScheduler.sync() } ACTION_SNOOZE -> runCatching { @@ -67,8 +65,7 @@ class ReminderActionReceiver : BroadcastReceiver() { val settings = settingsPrefs.settings.first() if (!settings.remindersEnabled) return@runCatching startupGate.awaitReady() - val task = dataSource.occurrence(taskId, occurrence) - if (task != null && !task.isClosed) notifier.postDue(task, settings) + notifier.postIfOpen(dataSource, taskId, occurrence, settings) } } } finally { diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderDiff.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderDiff.kt index 117b982..1787a25 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderDiff.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderDiff.kt @@ -21,7 +21,9 @@ internal data class ReminderDiff( fired: Set = emptySet(), isArmed: (ScheduledReminder) -> Boolean, ): ReminderDiff { - val live = previous.filterTo(HashSet()) { it in fired || isArmed(it) } + // A fired entry counts even once the store dropped it, so a task + // reopened within the grace does not ring again. + val live = previous.filterTo(HashSet()) { it in fired || isArmed(it) } + fired return ReminderDiff( cancel = live - desired, arm = desired - live, diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderRefresher.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderRefresher.kt new file mode 100644 index 0000000..6895cef --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderRefresher.kt @@ -0,0 +1,35 @@ +package de.jeanlucmakiola.agendula.data.reminders + +import de.jeanlucmakiola.agendula.data.di.ApplicationScope +import de.jeanlucmakiola.agendula.data.tasks.StoreChangeDebouncer +import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource +import kotlinx.coroutines.CoroutineScope +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Keeps reminders in step with the store: any change to tasks, lists or alarms — + * a tick in the list or on a widget, a sync pulling in a completion from another + * device, an edit, a store switch — runs one debounced [ReminderScheduler.sync]. + * Started once from `AgendulaApp`, after the startup gate, so it observes the + * right store. + */ +@Singleton +class ReminderRefresher @Inject constructor( + dataSource: TasksDataSource, + scheduler: ReminderScheduler, + @ApplicationScope scope: CoroutineScope, +) { + private val changes = StoreChangeDebouncer(scope, DEBOUNCE_MILLIS, dataSource::registerTaskObserver) { scheduler.sync() } + + fun start() { + changes.start() + } + + /** Re-sync soon, folded into any pass a store change already asked for. */ + fun requestRefresh() = changes.request() + + private companion object { + const val DEBOUNCE_MILLIS = 1_000L + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderScheduler.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderScheduler.kt index b1d54ff..73de104 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderScheduler.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderScheduler.kt @@ -8,6 +8,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import de.jeanlucmakiola.agendula.data.di.IoDispatcher import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver +import de.jeanlucmakiola.agendula.data.tasks.StorageMode import de.jeanlucmakiola.agendula.data.tasks.TaskQuery import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource import kotlinx.coroutines.CoroutineDispatcher @@ -23,8 +24,9 @@ import javax.inject.Singleton * The self-scheduled due-reminder engine. Nothing else delivers task reminders — * not the platform, not a tasks provider — so Agendula reads upcoming due tasks * and arms one exact [AlarmManager] alarm per reminder, within a rolling window. Re-run - * on app start, on boot, on a store switch, and on an external provider change; - * it diffs against [ScheduledReminderStore] so only changed alarms move. + * on app start, on boot, on a store switch and on every change to the store (see + * [ReminderRefresher]); it diffs against [ScheduledReminderStore] so only changed + * alarms move, and takes down posted reminders whose task is done with. */ @Singleton class ReminderScheduler @Inject constructor( @@ -34,6 +36,7 @@ class ReminderScheduler @Inject constructor( private val store: ScheduledReminderStore, private val providerResolver: ProviderResolver, private val snoozeScheduler: ReminderSnoozeScheduler, + private val notifier: TaskNotifier, @IoDispatcher private val io: CoroutineDispatcher, ) { private val syncLock = Mutex() @@ -65,19 +68,23 @@ class ReminderScheduler @Inject constructor( // Gate on whether the store is readable, not on whether a provider // resolves: our own store deliberately resolves to no provider, so the // latter clears every reminder in the default mode. - if (!settings.remindersEnabled || !providerResolver.canReadStore()) { + val readable = providerResolver.canReadStore() + if (!settings.remindersEnabled || !readable) { clearAll() + // Switched off, a reminder already up still goes when its task does. + if (readable) withdrawStale() return } val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) } .getOrElse { return } // Per-task leads. One query for all of them. val perTask = runCatching { dataSource.reminders() }.getOrElse { emptyMap() } + val now = System.currentTimeMillis() val desired = ReminderPlanner.plan( tasks = tasks, perTask = perTask, settings = settings, - now = System.currentTimeMillis(), + now = now, zone = ZoneId.systemDefault(), ) @@ -92,7 +99,10 @@ class ReminderScheduler @Inject constructor( // ⚠️ The store survives reboot, force-stop and restore; the alarms don't. // Trusting it alone left every reminder after a reboot unarmed for good. - val fired = store.fired() + // ⚠️ Kept through the missed-reminder grace even once it drops out of + // the plan: a tick undone a moment later would otherwise re-arm a + // trigger in the past, and the reminder would ring a second time. + val fired = store.fired().filterTo(HashSet()) { it in desired || it.triggerAt >= now - ReminderPlanner.MISSED_GRACE_MS } val diff = ReminderDiff.of(store.all(), desired, fired) { !afterReboot && !rearm && isArmed(it) } diff.cancel.forEach { cancel(it) } // ⚠️ Only what was actually armed. A throw mid-loop used to skip the @@ -100,7 +110,25 @@ class ReminderScheduler @Inject constructor( // — uncancellable, and firing for tasks that no longer exist — and the // exception escaped into BootReceiver's goAsync(). val armed = diff.arm.filter { schedule(it) } - store.replace(diff.keep + armed, fired = fired intersect desired, exact = canExact) + store.replace(diff.keep + armed, fired = fired, exact = canExact) + withdrawStale() + } + + /** + * A reminder already on screen outlives its alarm: completing the task in + * the list, on a widget or on another device has to take it down too. + */ + private fun withdrawStale() { + // Only our own store tells a deleted task from a failed read. + val missingMeansGone = providerResolver.mode() == StorageMode.OWN + runCatching { + notifier.withdrawStale { taskId, occurrence -> + runCatching { + val current = dataSource.occurrence(taskId, occurrence ?: ScheduledReminder.NO_OCCURRENCE) + reminderIsStale(occurrence, current, missingMeansGone) + }.getOrDefault(false) + } + } } /** Whether the system still holds this alarm's PendingIntent. */ diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/TaskNotifier.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/TaskNotifier.kt index d7f087d..d7f70e6 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/TaskNotifier.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/TaskNotifier.kt @@ -10,6 +10,7 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.os.Build +import android.os.Bundle import android.text.format.DateFormat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat @@ -18,6 +19,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 +86,7 @@ class TaskNotifier @Inject constructor( ), ) } + val description = task.description?.readableDescription()?.trim()?.takeIf { it.isNotBlank() } val tapIntent = PendingIntent.getActivity( context, task.taskId.toInt(), @@ -93,11 +96,19 @@ 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) .setContentIntent(tapIntent) + .addExtras( + Bundle().apply { + putLong(EXTRA_OCCURRENCE, task.occurrenceStart?.toEpochMilliseconds() ?: ScheduledReminder.NO_OCCURRENCE) + }, + ) .addAction(0, context.getString(R.string.reminder_action_done), actionIntent(task, ReminderActionReceiver.ACTION_DONE)) .addAction( 0, @@ -112,6 +123,24 @@ class TaskNotifier @Inject constructor( NotificationManagerCompat.from(context).cancel(taskId.toString(), NOTIFICATION_ID) } + /** + * Takes down every posted reminder [isStale] says is done with — completed, + * cancelled or deleted since it went off, here or on another device. The + * occurrence is null for a reminder posted before it was recorded. + */ + fun withdrawStale(isStale: (taskId: Long, occurrenceStart: Long?) -> Boolean) { + // Only the current store's: another store's task ids name other tasks. + val prefix = "$LIST_CHANNEL_PREFIX${resolver.mode().name.lowercase()}_" + manager.activeNotifications + .filter { it.id == NOTIFICATION_ID && it.notification.channelId?.startsWith(prefix) == true } + .forEach { posted -> + val taskId = posted.tag?.toLongOrNull() ?: return@forEach + val extras = posted.notification.extras + val occurrence = if (extras.containsKey(EXTRA_OCCURRENCE)) extras.getLong(EXTRA_OCCURRENCE) else null + if (isStale(taskId, occurrence)) cancel(taskId) + } + } + private fun actionIntent(task: Task, action: String): PendingIntent = PendingIntent.getBroadcast( context, task.taskId.toInt(), @@ -185,6 +214,7 @@ class TaskNotifier @Inject constructor( private const val LEGACY_CHANNEL_ID = "task_reminders" private const val LIST_CHANNEL_PREFIX = "reminders_" private const val NOTIFICATION_ID = 1 + private const val EXTRA_OCCURRENCE = "de.jeanlucmakiola.agendula.extra.OCCURRENCE_START" private const val CHANNEL_PREFS = "reminder_channels" private const val KEY_LEGACY_IMPORTANCE = "legacy_importance" @@ -240,3 +270,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" +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/ManualSync.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/ManualSync.kt new file mode 100644 index 0000000..0f3f89b --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/ManualSync.kt @@ -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 + + /** True from [syncAll] until the syncs it started have finished. */ + val syncing: StateFlow + + fun syncAll() + + object None : ManualSync { + override val available: Flow = flowOf(false) + override val syncing: StateFlow = MutableStateFlow(false) + override fun syncAll() = Unit + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncRequests.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncRequests.kt index a95da71..419e14a 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncRequests.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncRequests.kt @@ -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) } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ModeRoutingTasksDataSource.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ModeRoutingTasksDataSource.kt index cbfd92a..cb0ef65 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ModeRoutingTasksDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ModeRoutingTasksDataSource.kt @@ -81,16 +81,22 @@ class ModeRoutingTasksDataSource( * So the registration moves with the mode, and the switch itself counts as a * change: the data underneath every live flow has just been replaced. */ - override fun registerObserver(onChange: () -> Unit): AutoCloseable { + override fun registerObserver(onChange: () -> Unit): AutoCloseable = + follow(onChange) { it.registerObserver(onChange) } + + override fun registerTaskObserver(onChange: () -> Unit): AutoCloseable = + follow(onChange) { it.registerTaskObserver(onChange) } + + private fun follow(onChange: () -> Unit, register: (TasksDataSource) -> AutoCloseable): AutoCloseable { val lock = Any() var closed = false - var handle: AutoCloseable? = runCatching { active().registerObserver(onChange) }.getOrNull() + var handle: AutoCloseable? = runCatching { register(active()) }.getOrNull() val modeHandle = resolver.onModeChanged { synchronized(lock) { if (!closed) { handle?.let { runCatching { it.close() } } - handle = runCatching { active().registerObserver(onChange) }.getOrNull() + handle = runCatching { register(active()) }.getOrNull() } } onChange() diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StoreChangeDebouncer.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StoreChangeDebouncer.kt new file mode 100644 index 0000000..7c58e5f --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StoreChangeDebouncer.kt @@ -0,0 +1,39 @@ +package de.jeanlucmakiola.agendula.data.tasks + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Runs [action] once per burst of store changes: [register] hooks the store's + * observer, and [request] asks for a pass without one. Started at most once. + */ +class StoreChangeDebouncer( + private val scope: CoroutineScope, + private val delayMillis: Long, + private val register: (onChange: () -> Unit) -> AutoCloseable, + private val action: suspend () -> Unit, +) { + private val requests = Channel(Channel.CONFLATED) + private val started = AtomicBoolean(false) + + /** @return whether this call started it; false when it already was. */ + @OptIn(FlowPreview::class) + fun start(): Boolean { + if (!started.compareAndSet(false, true)) return false + runCatching { register { requests.trySend(Unit) } } + scope.launch(Dispatchers.IO) { + requests.receiveAsFlow().debounce(delayMillis).collect { runCatching { action() } } + } + return true + } + + fun request() { + requests.trySend(Unit) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StoredReminders.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StoredReminders.kt index f67ffcc..f16cffc 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StoredReminders.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StoredReminders.kt @@ -5,6 +5,7 @@ import de.jeanlucmakiola.agendula.data.reminders.ReminderPlanner import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskReminder +import de.jeanlucmakiola.agendula.domain.calendarDate import java.time.ZoneId import kotlin.time.Instant @@ -22,7 +23,8 @@ internal object StoredReminders { * * A task getting its first date takes the defaults only when the form * names no reminder; one that already had a date takes those of the new - * date that no reminder of the form counts from. Otherwise the form is + * date that no reminder of the form counts from, and those of a date + * whose defaults had all rung when it moved — see [revived]. Otherwise the form is * kept, so an edit that clears every reminder means "use the default * here" as before. * @@ -33,7 +35,7 @@ internal object StoredReminders { fun of(form: TaskForm, current: Task?, settings: Settings, now: Long, zone: ZoneId): List { val wanted = form.reminders.map { it.lead }.distinct() if (form.remindersChosen) return wanted - return withDefaults(form, current?.start, current?.due, settings, now, zone) + return withDefaults(form, current?.start, current?.due, current?.isAllDay ?: form.isAllDay, settings, now, zone) } /** [of] for a task whose dates were [start] and [due], whatever the form chose. */ @@ -41,6 +43,7 @@ internal object StoredReminders { form: TaskForm, start: Instant?, due: Instant?, + wasAllDay: Boolean, settings: Settings, now: Long, zone: ZoneId, @@ -48,10 +51,12 @@ internal object StoredReminders { val wanted = form.reminders.map { it.lead }.distinct() val had = anchorsOf(start, due) val gained = anchorsOf(form.start, form.due) - had - if (gained.isEmpty()) return wanted + val revived = revived(form, start, due, wasAllDay, wanted, settings, now, zone) + if (gained.isEmpty() && revived.isEmpty()) return wanted if (had.isEmpty() || wanted.isEmpty()) return wanted.ifEmpty { defaultsFor(form, settings, now, zone) } val named = wanted.map { it.fromStart }.toSet() - return wanted + defaultsFor(form, settings, now, zone).filter { it.fromStart in gained && it.fromStart !in named } + val fresh = gained + revived + return wanted + defaultsFor(form, settings, now, zone).filter { it.fromStart in fresh && it.fromStart !in named } } /** @@ -79,6 +84,41 @@ internal object StoredReminders { return kept.distinct().ifEmpty { defaultsFor(form, settings, now, zone) } } + /** + * The dates of a one-off task that moved after every default of theirs + * had rung: a task saved then was left without them as past (#121), so + * the new date brings them like a date the task gains. Only on a task + * with reminders of its own and none from that date — no reminders at + * all is the planner's default or turned off, and either holds. + */ + private fun revived( + form: TaskForm, + start: Instant?, + due: Instant?, + wasAllDay: Boolean, + wanted: List, + settings: Settings, + now: Long, + zone: ZoneId, + ): Set { + if (form.rrule != null || wanted.isEmpty()) return emptySet() + val named = wanted.map { it.fromStart }.toSet() + val defaults = settings.defaultRemindersFor(form.listId, wasAllDay) + fun revived(fromStart: Boolean, was: Instant?, isNow: Instant?): Boolean { + if (was == null || isNow == null || fromStart in named) return false + val moved = if (wasAllDay == form.isAllDay) { + isNow != was + } else { + isNow.calendarDate(form.isAllDay, zone) != was.calendarDate(wasAllDay, zone) + } + val leads = defaults.filter { it.fromStart == fromStart } + return moved && leads.isNotEmpty() && leads.all { + ReminderPlanner.triggerAt(was, wasAllDay, it.minutesBefore, settings.allDayReminderMinuteOfDay, zone) < now + } + } + return setOfNotNull(true.takeIf { revived(true, start, form.start) }, false.takeIf { revived(false, due, form.due) }) + } + /** The dates a reminder can count from, as [TaskReminder.fromStart]. */ private fun anchorsOf(start: Any?, due: Any?): Set = setOfNotNull(true.takeIf { start != null }, false.takeIf { due != null }) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksDataSource.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksDataSource.kt index fe40f82..e516b93 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksDataSource.kt @@ -152,4 +152,7 @@ interface TasksDataSource { /** Observe any change to tasks/lists; [onChange] fires on a background thread. */ fun registerObserver(onChange: () -> Unit): AutoCloseable + + /** Like [registerObserver], but only for changes to tasks, lists and alarms — not account bookkeeping. */ + fun registerTaskObserver(onChange: () -> Unit): AutoCloseable = registerObserver(onChange) } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderWriter.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderWriter.kt index 7b1036f..b262286 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderWriter.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderWriter.kt @@ -1,8 +1,7 @@ package de.jeanlucmakiola.agendula.data.tasks.provider import android.database.sqlite.SQLiteConstraintException -import de.jeanlucmakiola.agendula.data.di.ApplicationScope -import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler +import de.jeanlucmakiola.agendula.data.reminders.ReminderRefresher import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver import de.jeanlucmakiola.agendula.data.tasks.StorageMode import de.jeanlucmakiola.agendula.data.tasks.TaskWriteFailedException @@ -19,8 +18,6 @@ import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase import de.jeanlucmakiola.agendula.data.tasks.room.seriesOccurrences import de.jeanlucmakiola.agendula.domain.TaskStatus import de.jeanlucmakiola.agendula.domain.normalizeTags -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch import java.time.ZoneId import java.util.UUID import javax.inject.Inject @@ -58,9 +55,8 @@ class ProviderWriter internal constructor( store: RoomTasksDataSource, source: ProviderSnapshotSource, resolver: ProviderResolver, - scheduler: ReminderScheduler, - @ApplicationScope scope: CoroutineScope, - ) : this(database, store, source, resolver, { scope.launch { runCatching { scheduler.sync() } } }) + reminders: ReminderRefresher, + ) : this(database, store, source, resolver, reminders::requestRefresh) private var batchDepth = 0 private var batchWrote = false diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/AccountDao.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/AccountDao.kt index a734b9b..222f5ec 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/AccountDao.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/AccountDao.kt @@ -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 } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Entities.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Entities.kt index cad74ee..d668783 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Entities.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Entities.kt @@ -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, ) /** diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Projections.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Projections.kt index 74f69d4..98c321c 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Projections.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Projections.kt @@ -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, ) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapper.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapper.kt index 5ef7233..fb67ce8 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapper.kt @@ -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, diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSource.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSource.kt index f97b16f..60edba7 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSource.kt @@ -76,6 +76,7 @@ internal fun seriesOccurrences(master: TaskEntity, overrides: List, } internal val OBSERVED_TABLES = arrayOf("tasks", "task_lists", "task_alarms", "accounts") +private val TASK_TABLES = arrayOf("tasks", "task_lists", "task_alarms") /** Distinct alarm texts whose `ACKNOWLEDGED` is remembered between scheduler passes. */ private const val ACK_CACHE_SIZE = 512 @@ -650,8 +651,12 @@ class RoomTasksDataSource @Inject constructor( // --- observation ---------------------------------------------------------- - override fun registerObserver(onChange: () -> Unit): AutoCloseable { - val observer = object : InvalidationTracker.Observer(OBSERVED_TABLES) { + override fun registerObserver(onChange: () -> Unit): AutoCloseable = observe(OBSERVED_TABLES, onChange) + + override fun registerTaskObserver(onChange: () -> Unit): AutoCloseable = observe(TASK_TABLES, onChange) + + private fun observe(tables: Array, onChange: () -> Unit): AutoCloseable { + val observer = object : InvalidationTracker.Observer(tables) { override fun onInvalidated(tables: Set) = onChange() } database.invalidationTracker.addObserver(observer) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskDao.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskDao.kt index 52d28f4..747ef2d 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskDao.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskDao.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskListDao.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskListDao.kt index 863e507..a5d6bd8 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskListDao.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskListDao.kt @@ -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 """ diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabase.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabase.kt index 9f9ac7e..6e24f41 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabase.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabase.kt @@ -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") + } + } } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/SavedFilter.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/SavedFilter.kt index e9a7764..294e651 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/domain/SavedFilter.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/SavedFilter.kt @@ -59,7 +59,7 @@ fun dueWindowOf( todayEnd: Instant, zone: ZoneId = ZoneId.systemDefault(), ): DueWindow { - val due = task.due?.let { if (task.isAllDay) it.rebasedForAllDay(allDay = false, zone = zone) else it } + val due = task.wallClock(task.due, zone) return when { due == null -> DueWindow.NO_DATE due < todayStart -> DueWindow.OVERDUE @@ -68,6 +68,10 @@ fun dueWindowOf( } } +/** [at], one of this task's dates, as a local instant: an all-day date is read as its calendar date. */ +fun Task.wallClock(at: Instant?, zone: ZoneId = ZoneId.systemDefault()): Instant? = + at?.let { if (isAllDay) it.rebasedForAllDay(allDay = false, zone = zone) else it } + /** What a task created from a [SavedFilter] starts with, so it lands in that filter. */ data class NewTaskPreset( val listIds: Set = emptySet(), diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskFilter.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskFilter.kt index 274fbb2..e898062 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskFilter.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskFilter.kt @@ -1,8 +1,13 @@ package de.jeanlucmakiola.agendula.domain +import java.time.ZoneId import kotlin.time.Instant -/** The built-in "smart" lists, computed from due dates rather than membership. */ +/** + * The built-in "smart" lists, computed from dates rather than membership. All + * but [COMPLETED] hold open tasks, except [TODAY], which also keeps the ones + * completed today (see [TaskFiltering.isToday]). + */ enum class SmartList { ALL, TODAY, UPCOMING, OVERDUE, NO_DATE, COMPLETED } /** What a task screen is showing: one real list, a smart list, or a tag. */ @@ -40,6 +45,20 @@ object TaskFiltering { is TaskFilter.Saved -> saved?.takeIf { it.id == filter.id }?.matches(task, todayStart, todayEnd) ?: false } + /** + * Whether [task] belongs in Today. An open task does when it is due today, or + * starts today without being overdue; a completed one when it was completed today. + */ + fun isToday(task: Task, todayStart: Instant, todayEnd: Instant, zone: ZoneId = ZoneId.systemDefault()): Boolean { + fun inToday(at: Instant?) = at != null && at >= todayStart && at < todayEnd + if (task.isClosed) return task.isCompleted && inToday(task.completedAt) + return when (dueWindowOf(task, todayStart, todayEnd, zone)) { + DueWindow.TODAY -> true + DueWindow.OVERDUE -> false + DueWindow.UPCOMING, DueWindow.NO_DATE -> inToday(task.wallClock(task.start, zone)) + } + } + /** Whether [task] carries every one of [tags]; an empty set matches everything. */ fun hasAllTags(task: Task, tags: Set): Boolean = task.tags.containsAll(tags) @@ -49,7 +68,7 @@ object TaskFiltering { SmartList.ALL -> !task.isClosed SmartList.NO_DATE -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.NO_DATE SmartList.OVERDUE -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.OVERDUE - SmartList.TODAY -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.TODAY + SmartList.TODAY -> isToday(task, todayStart, todayEnd) SmartList.UPCOMING -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.UPCOMING } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskSections.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskSections.kt index 63bbdf7..181de9f 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskSections.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskSections.kt @@ -16,19 +16,22 @@ data class SectionedTasks(val section: TaskSection, val tasks: List) * * [todayStart] is local midnight today and [todayEnd] local midnight tomorrow * (compute with [DayWindow]). Only non-empty sections are returned, in the - * fixed order Overdue → Today → Upcoming → No date → Completed. + * fixed order Overdue → Today → Upcoming → No date → Completed. In the Today + * list ([todayList]) every open task sits under Today, as it may be there by its + * start date alone. */ object TaskSections { - fun of(tasks: List, todayStart: Instant, todayEnd: Instant): List { - val bySection = tasks.groupBy { sectionOf(it, todayStart, todayEnd) } + fun of(tasks: List, todayStart: Instant, todayEnd: Instant, todayList: Boolean = false): List { + val bySection = tasks.groupBy { sectionOf(it, todayStart, todayEnd, todayList) } return TaskSection.entries.mapNotNull { section -> bySection[section]?.takeIf { it.isNotEmpty() }?.let { SectionedTasks(section, it) } } } - fun sectionOf(task: Task, todayStart: Instant, todayEnd: Instant): TaskSection { + fun sectionOf(task: Task, todayStart: Instant, todayEnd: Instant, todayList: Boolean = false): TaskSection { if (task.isCompleted) return TaskSection.COMPLETED + if (todayList) return TaskSection.TODAY return when (dueWindowOf(task, todayStart, todayEnd)) { DueWindow.OVERDUE -> TaskSection.OVERDUE DueWindow.TODAY -> TaskSection.TODAY diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/RootScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/RootScreen.kt index 0e6da56..86e4a59 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/RootScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/RootScreen.kt @@ -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) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/EdgeSwipeGuard.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/EdgeSwipeGuard.kt new file mode 100644 index 0000000..d138ae9 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/EdgeSwipeGuard.kt @@ -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 + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/SyncPullBox.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/SyncPullBox.kt new file mode 100644 index 0000000..efccbbf --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/SyncPullBox.kt @@ -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, + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditViewModel.kt index e01423d..1261482 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditViewModel.kt @@ -6,7 +6,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel import de.jeanlucmakiola.agendula.data.prefs.DEFAULT_ALL_DAY_REMINDER_MINUTE import de.jeanlucmakiola.agendula.data.prefs.Settings import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs -import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler import de.jeanlucmakiola.agendula.data.tasks.TaskConflictException import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.domain.Priority @@ -109,7 +108,6 @@ data class TaskEditUiState( class TaskEditViewModel @Inject constructor( private val repository: TasksRepository, private val settingsPrefs: SettingsPrefs, - private val reminderScheduler: ReminderScheduler, ) : ViewModel() { private val _state = MutableStateFlow(TaskEditUiState()) @@ -149,6 +147,7 @@ class TaskEditViewModel @Inject constructor( private var loadedReminders: List = emptyList() private var loadedStart: Instant? = null private var loadedDue: Instant? = null + private var loadedAllDay = false /** Start a fresh task, optionally pre-selecting a list / parent. */ fun bindNew( @@ -157,6 +156,7 @@ class TaskEditViewModel @Inject constructor( initialTitle: String? = null, presetTag: String? = null, presetFilterId: Long? = null, + presetDueToday: Boolean = false, ) { if (bound) return bound = true @@ -169,6 +169,7 @@ class TaskEditViewModel @Inject constructor( val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() } val preset = presetFilterId?.let { settingsPrefs.savedFilter(it).first() } ?.newTaskPreset(LocalDate.now()) + val presetDue = preset?.dueDate ?: LocalDate.now().takeIf { presetDueToday } // A read-only or vanished default — or a widget's preset list, if it was // since deleted or turned read-only — would give the task a list it // cannot be saved to. @@ -190,12 +191,12 @@ class TaskEditViewModel @Inject constructor( // today's start would land after a due of today. start = when { !settings.defaultStartToday -> null - preset?.dueDate != null -> allDayInstantOf(LocalDate.now()) + presetDue != null -> allDayInstantOf(LocalDate.now()) else -> Clock.System.now() }, // The same all-day value picking the date in the date field gives. - due = preset?.dueDate?.let(::allDayInstantOf), - isAllDay = preset?.dueDate != null, + due = presetDue?.let(::allDayInstantOf), + isAllDay = presetDue != null, priority = preset?.priority ?: Priority.NONE, parentId = parentId, lists = lists, @@ -228,6 +229,7 @@ class TaskEditViewModel @Inject constructor( storedReminders = loadedReminders.toSet() loadedStart = task.start loadedDue = task.due + loadedAllDay = task.isAllDay _state.value = withFields( TaskEditUiState( loading = false, @@ -362,6 +364,7 @@ class TaskEditViewModel @Inject constructor( toForm().copy(reminders = loadedReminders), loadedStart, loadedDue, + loadedAllDay, settings, nowMillis(), ZoneId.systemDefault(), @@ -446,13 +449,6 @@ class TaskEditViewModel @Inject constructor( scope = scope, ) } - // ⚠️ Outside the write's own result. The task is saved by this - // point, and reporting a scheduling failure as a *save* failure - // sends the user back to tap Save again — on a screen whose - // editingTaskId is still null, which creates a second task. The - // reminder is re-synced on the next data change and on launch; - // the duplicate is forever. - runCatching { reminderScheduler.sync() } }.onSuccess { _state.value = _state.value.copy(saved = true, saveFailed = false, saveConflict = false) }.onFailure { error -> diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/imports/ImportViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/imports/ImportViewModel.kt index 4c03174..239b770 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/imports/ImportViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/imports/ImportViewModel.kt @@ -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, diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt index f64cd57..52906ad 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt @@ -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 -> diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt index 90f2c18..1a04a1a 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt @@ -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 = + manualSync.available.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) + val syncing: StateFlow = manualSync.syncing + + fun syncAll() = manualSync.syncAll() + /** * Where a new list may go: the device, then every account whose server said * it would take one. @@ -163,14 +172,12 @@ class ListsViewModel @Inject constructor( SmartCount(SmartList.ALL, smartTopLevel.size), ) - // Today ring: completed vs. total tasks *due today*. The numerator is the - // top-level completed tasks whose due date falls in today's window; the - // denominator adds the still-open ones (the Today smart count above). + // Today ring: the completed rows of the Today list against all of its rows. val openToday = count(SmartList.TODAY) - val completedDueToday = completedTasks.count { - !it.isSubtask && it.listId !in hidden && it.due != null && it.due >= todayStart && it.due < todayEnd + val completedToday = completedTasks.count { + !it.isSubtask && it.listId !in hidden && TaskFiltering.isToday(it, todayStart, todayEnd) } - val todayTotal = openToday + completedDueToday + val todayTotal = openToday + completedToday // Upcoming preview: the next handful of open tasks due tomorrow onward, // already sorted by the repository's default ordering. @@ -205,7 +212,7 @@ class ListsViewModel @Inject constructor( smartCounts = smartCounts, filters = filters, groups = groups, - todayDone = completedDueToday, + todayDone = completedToday, todayTotal = todayTotal, upcoming = upcoming, allTasks = allTasks, diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/AgendulaNavHost.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/AgendulaNavHost.kt index 0365733..e3d942d 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/AgendulaNavHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/AgendulaNavHost.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.agendula.ui.navigation +import android.annotation.SuppressLint import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.animation.fadeIn @@ -7,11 +8,17 @@ 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 import androidx.compose.ui.unit.IntOffset import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import de.jeanlucmakiola.agendula.domain.SmartList import de.jeanlucmakiola.agendula.domain.Task import kotlin.time.Instant import androidx.navigation.compose.NavHost @@ -46,8 +53,21 @@ 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(null) } + // No public API exposes the whole back stack, and visibleEntries drops an entry covered by another. + @SuppressLint("RestrictedApi") + val backStack by nav.currentBackStack.collectAsState() + LaunchedEffect(backStack, returnHomeEntry) { + val id = returnHomeEntry ?: return@LaunchedEffect + if (backStack.none { it.id == id }) { + returnHomeEntry = null + onReturnHome() + } + } LaunchedEffect(navRequest) { when (navRequest) { @@ -59,7 +79,9 @@ fun AgendulaNavHost( is NavRequest.OpenAccount -> nav.navigate(Dest.Settings.buildAccount(navRequest.accountId)) { launchSingleTop = true } is NavRequest.NewTask -> - nav.navigate(Dest.TaskEdit.buildNew(presetListId = navRequest.listId, title = navRequest.title)) + nav.navigate( + Dest.TaskEdit.buildNew(presetListId = navRequest.listId, title = navRequest.title, dueToday = navRequest.dueToday), + ) is NavRequest.OpenSmart -> nav.navigate(Dest.TaskList.build(TaskFilter.Smart(navRequest.list))) { launchSingleTop = true } @@ -71,6 +93,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() } @@ -141,6 +166,7 @@ fun AgendulaNavHost( presetListId = (filter as? TaskFilter.OfList)?.listId, tag = (filter as? TaskFilter.Tagged)?.tag, filterId = (filter as? TaskFilter.Saved)?.id, + dueToday = filter == TaskFilter.Smart(SmartList.TODAY), ), ) }, @@ -212,6 +238,7 @@ fun AgendulaNavHost( val title = args?.getString(Dest.TaskEdit.ARG_TITLE) val tag = args?.getString(Dest.TaskEdit.ARG_TAG) val filterId = args?.getLong(Dest.TaskEdit.ARG_FILTER_ID)?.takeIf { it != Dest.NO_ID } + val dueToday = args?.getBoolean(Dest.TaskEdit.ARG_DUE_TODAY) ?: false val vm: TaskEditViewModel = hiltViewModel() LaunchedEffect(taskId, presetListId, parentId) { if (taskId != Dest.NO_ID) { @@ -223,6 +250,7 @@ fun AgendulaNavHost( initialTitle = title, presetTag = tag, presetFilterId = filterId, + presetDueToday = dueToday, ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/Destinations.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/Destinations.kt index d3b6168..a67668e 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/Destinations.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/Destinations.kt @@ -119,7 +119,7 @@ object Dest { /** * The create / edit form. `taskId` set ([NO_ID] otherwise) ⇒ edit; for a new * task, `presetListId` / `parentId` optionally seed the form, and `filterId` - * fills it so the task lands in that saved filter. + * (or `dueToday`, for the Today list) fills it so the task lands in that view. */ object TaskEdit { const val ARG_TASK_ID = "taskId" @@ -129,6 +129,7 @@ object Dest { const val ARG_TITLE = "title" const val ARG_TAG = "tag" const val ARG_FILTER_ID = "filterId" + const val ARG_DUE_TODAY = "dueToday" const val route = "taskEdit?$ARG_TASK_ID={$ARG_TASK_ID}" + "&$ARG_PRESET_LIST_ID={$ARG_PRESET_LIST_ID}" + @@ -136,7 +137,8 @@ object Dest { "&$ARG_OCCURRENCE={$ARG_OCCURRENCE}" + "&$ARG_TITLE={$ARG_TITLE}" + "&$ARG_TAG={$ARG_TAG}" + - "&$ARG_FILTER_ID={$ARG_FILTER_ID}" + "&$ARG_FILTER_ID={$ARG_FILTER_ID}" + + "&$ARG_DUE_TODAY={$ARG_DUE_TODAY}" val arguments = listOf( navArgument(ARG_TASK_ID) { type = NavType.LongType; defaultValue = NO_ID }, @@ -146,6 +148,7 @@ object Dest { navArgument(ARG_TITLE) { type = NavType.StringType; nullable = true; defaultValue = null }, navArgument(ARG_TAG) { type = NavType.StringType; nullable = true; defaultValue = null }, navArgument(ARG_FILTER_ID) { type = NavType.LongType; defaultValue = NO_ID }, + navArgument(ARG_DUE_TODAY) { type = NavType.BoolType; defaultValue = false }, ) fun buildEdit(taskId: Long, occurrenceStart: Long? = null): String = @@ -158,6 +161,7 @@ object Dest { title: String? = null, tag: String? = null, filterId: Long? = null, + dueToday: Boolean = false, ): String { val params = buildList { if (presetListId != null) add("$ARG_PRESET_LIST_ID=$presetListId") @@ -165,6 +169,7 @@ object Dest { if (!title.isNullOrBlank()) add("$ARG_TITLE=${android.net.Uri.encode(title)}") if (!tag.isNullOrBlank()) add("$ARG_TAG=${android.net.Uri.encode(tag)}") if (filterId != null) add("$ARG_FILTER_ID=$filterId") + if (dueToday) add("$ARG_DUE_TODAY=true") } return if (params.isEmpty()) "taskEdit" else "taskEdit?${params.joinToString("&")}" } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/NavRequest.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/NavRequest.kt index 3743cb3..f61ea27 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/NavRequest.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/NavRequest.kt @@ -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 @@ -20,9 +27,16 @@ 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. + * preset to a list — a widget's own add button, when it shows one list — + * or due today, from a Today widget. + * [returnHome] leaves the app once the form is closed (#93). */ - data class NewTask(val title: String? = null, val listId: Long? = null) : NavRequest + data class NewTask( + val title: String? = null, + val listId: Long? = null, + val dueToday: Boolean = false, + val returnHome: Boolean = false, + ) : NavRequest /** One of the smart lists — the launcher's "Today" shortcut, a widget header. */ data class OpenSmart(val list: SmartList) : NavRequest diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/AppearanceScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/AppearanceScreen.kt index 7c1ce72..77680be 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/AppearanceScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/AppearanceScreen.kt @@ -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 } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt index 19e4b9a..3be4765 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt @@ -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() { @@ -184,7 +188,6 @@ class SettingsViewModel @Inject constructor( transferState.update { it.copy(confirming = false, running = true, outcome = null) } viewModelScope.launch { val result = externalImport.run() - if (result is TransferResult.Copied) runCatching { reminderScheduler.sync() } transferState.update { it.copy( running = false, @@ -208,7 +211,6 @@ class SettingsViewModel @Inject constructor( retryingLegacyImport.value = true viewModelScope.launch { runCatching { oneShotImport.reimportFromArchive() } - runCatching { reminderScheduler.sync() } retryingLegacyImport.value = false } } @@ -217,6 +219,23 @@ class SettingsViewModel @Inject constructor( val legacyImportRetrying: StateFlow = retryingLegacyImport + private val _launcherName = MutableStateFlow(LauncherName.AGENDULA) + val launcherName: StateFlow = _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) } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt index 337cd6f..02a7dd6 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt @@ -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 @@ -355,7 +367,7 @@ private fun TaskListBody( viewModel: TaskListViewModel, onClearCompleted: (() -> Unit)? = null, ) { - val (todayStart, todayEnd) = remember { DayWindow.today(Clock.System.now(), ZoneId.systemDefault()) } + val (todayStart, todayEnd) = remember(state.tasks) { DayWindow.today(Clock.System.now(), ZoneId.systemDefault()) } // A parent represents its children via a progress chip (counts come from the // repository, over the full set), so drop children from the flat list — unless // their parent isn't in this view (can happen in smart lists), where we keep @@ -364,15 +376,15 @@ private fun TaskListBody( val present = state.tasks.mapTo(HashSet()) { it.taskId } state.tasks.filter { (it.parentId ?: 0L) <= 0L || it.parentId !in present } } - val sections = remember(displayTasks) { TaskSections.of(displayTasks, todayStart, todayEnd) } + val sections = remember(displayTasks, todayStart) { + TaskSections.of(displayTasks, todayStart, todayEnd, todayList = filter == TaskFilter.Smart(SmartList.TODAY)) + } val showHeaders = sections.size > 1 val listId = (filter as? TaskFilter.OfList)?.listId // In a mixed view (a smart list) the list colour bar + name disambiguate // 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()) } @@ -420,23 +432,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 +664,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 +1046,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 +1136,6 @@ private fun cardShape(position: Position, full: androidx.compose.ui.unit.Dp, sma Position.Bottom -> RoundedCornerShape(topStart = small, topEnd = small, bottomStart = full, bottomEnd = full) } -@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( diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt index fdf75d7..b74fece 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt @@ -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 = 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 = + manualSync.available.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) + val syncing: StateFlow = manualSync.syncing + + fun syncAll() = manualSync.syncAll() + private val filter = MutableStateFlow(null) private val tagFilter = MutableStateFlow>(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) } /** diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/theme/SystemTheme.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/theme/SystemTheme.kt new file mode 100644 index 0000000..963a52a --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/theme/SystemTheme.kt @@ -0,0 +1,44 @@ +package de.jeanlucmakiola.agendula.ui.theme + +import android.app.Activity +import android.app.UiModeManager +import android.os.Build +import androidx.annotation.StyleRes +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.data.prefs.ThemeMode + +/** + * Hands the app's theme setting to the system, which draws the launch window + * and splash before any of the app runs (#119). The system keeps both across + * launches, so the next start already matches. + */ +object SystemTheme { + + /** The light or dark the app's resources follow, as [UiModeManager.setApplicationNightMode] takes it. */ + fun nightModeOf(mode: ThemeMode): Int = when (mode) { + ThemeMode.SYSTEM -> UiModeManager.MODE_NIGHT_AUTO + ThemeMode.LIGHT -> UiModeManager.MODE_NIGHT_NO + ThemeMode.DARK, ThemeMode.BLACK -> UiModeManager.MODE_NIGHT_YES + } + + /** The splash theme whose background is the one the app opens on. */ + @StyleRes + fun splashThemeOf(mode: ThemeMode, dynamicColor: Boolean): Int = when { + mode.isPureBlack -> R.style.Theme_Agendula_Black + dynamicColor -> R.style.Theme_Agendula + else -> R.style.Theme_Agendula_Fallback + } + + /** + * Applies [mode] and [dynamicColor]. A night mode the activity does not + * have yet recreates it, once; the same one again changes nothing. + */ + fun apply(activity: Activity, mode: ThemeMode, dynamicColor: Boolean) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + activity.getSystemService(UiModeManager::class.java)?.setApplicationNightMode(nightModeOf(mode)) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + activity.splashScreen.setSplashScreenTheme(splashThemeOf(mode, dynamicColor)) + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/theme/Theme.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/theme/Theme.kt index 2bca6f8..6e02f5f 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/theme/Theme.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/theme/Theme.kt @@ -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, ) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidget.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidget.kt index c4eedcf..291d797 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidget.kt @@ -194,10 +194,12 @@ private fun TaskWidgetBody( Spacer(GlanceModifier.height(R.dimen.widget_tasks_header_gap_tight)) WidgetDoneState(context.getString(R.string.widget_empty), context.getString(R.string.widget_empty_hint)) } else { + val todayList = filter == TaskFilter.Smart(SmartList.TODAY) val allSections = widgetSections( data.rows, data.groupByList, data.listNames, + todayList, ) { kind -> context.getString(dueBucketLabel(kind)) } // A lone group whose bucket repeats the widget's own smart list // (the "Today" smart list showing one "Today" bucket) earns no @@ -248,7 +250,8 @@ private fun TaskWidgetBody( // (Today / Tomorrow) whenever one is shown — and when // it isn't, that's only because it would have repeated // the title, which names the same day just as well. - suppressDayLabel = !data.groupByList, + suppressDayLabel = !data.groupByList && + (!todayList || section.row.dueKind == DueKind.TODAY), ) Spacer(GlanceModifier.height(8.dp)) } @@ -304,7 +307,9 @@ private fun TaskWidgetHeader(title: String, filter: TaskFilter, singleListId: Lo WidgetHeaderAction( icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add), contentDescription = context.getString(R.string.new_task), - action = actionStartActivity(MainActivity.newTaskIntent(context, singleListId)), + action = actionStartActivity( + MainActivity.newTaskIntent(context, singleListId, dueToday = filter == TaskFilter.Smart(SmartList.TODAY)), + ), ) } } @@ -328,7 +333,7 @@ internal fun AgendaTaskRow( val context = LocalContext.current val 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 diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetConfigActivity.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetConfigActivity.kt index 7287add..504720c 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetConfigActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetConfigActivity.kt @@ -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, diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetUpdater.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetUpdater.kt index 5802a6c..78befa3 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetUpdater.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetUpdater.kt @@ -3,14 +3,11 @@ package de.jeanlucmakiola.agendula.widget import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext import de.jeanlucmakiola.agendula.data.di.ApplicationScope +import de.jeanlucmakiola.agendula.data.tasks.StoreChangeDebouncer import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource import de.jeanlucmakiola.floret.glance.WidgetRollover import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import javax.inject.Inject import javax.inject.Singleton @@ -26,16 +23,10 @@ class TaskWidgetUpdater @Inject constructor( private val dataSource: TasksDataSource, @ApplicationScope private val scope: CoroutineScope, ) { - private val requests = Channel(Channel.CONFLATED) - private var observer: AutoCloseable? = null + private val changes = StoreChangeDebouncer(scope, DEBOUNCE_MILLIS, dataSource::registerObserver, ::redraw) - @OptIn(FlowPreview::class) fun start() { - if (observer != null) return - observer = runCatching { dataSource.registerObserver { requests.trySend(Unit) } }.getOrNull() - scope.launch(Dispatchers.IO) { - requests.receiveAsFlow().debounce(DEBOUNCE_MILLIS).collect { redraw() } - } + if (!changes.start()) return scope.launch(Dispatchers.IO) { runCatching { WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent) } } @@ -44,9 +35,7 @@ class TaskWidgetUpdater @Inject constructor( } /** Redraw soon; bursts collapse into one pass. */ - fun requestRefresh() { - requests.trySend(Unit) - } + fun requestRefresh() = changes.request() private suspend fun redraw() { runCatching { diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TodayWidget.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TodayWidget.kt index f5922e1..f0b2cfb 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TodayWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TodayWidget.kt @@ -100,7 +100,7 @@ private fun TodayWidgetBody(data: WidgetStatsData?, background: WidgetBackground WidgetHeaderAction( icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add), contentDescription = context.getString(R.string.new_task), - action = actionStartActivity(MainActivity.newTaskIntent(context)), + action = actionStartActivity(MainActivity.newTaskIntent(context, dueToday = true)), ) } Spacer(GlanceModifier.height(HeaderGap)) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/UpNextWidget.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/UpNextWidget.kt index ad3098a..0336f9e 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/UpNextWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/UpNextWidget.kt @@ -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( diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetData.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetData.kt index e1c0c34..42b43dd 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetData.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetData.kt @@ -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. */ @@ -200,12 +200,14 @@ private fun headedRuns(runs: List): List = * (already sorted that way by [widgetRows]), otherwise one per [DueKind] * bucket — "Overdue" / "Today" / "Tomorrow" / "Later" / "No date" — in that * fixed order, since [rows] isn't due-sorted. Each run becomes one header - * followed by its own standalone, independently coloured task pills. + * followed by its own standalone, independently coloured task pills. The Today + * list ([todayList]) is one "Today" bucket, as a row may be there by its start. */ internal fun widgetSections( rows: List, groupByList: Boolean, listNames: Map, + todayList: Boolean = false, dueBucketLabel: (DueKind?) -> String, ): List { if (groupByList) { @@ -220,7 +222,7 @@ internal fun widgetSections( } return headedRuns(runs) } - val buckets = rows.groupBy { it.dueKind } + val buckets = rows.groupBy { if (todayList) DueKind.TODAY else it.dueKind } val runs = DueKind.entries.plus(null).mapNotNull { kind -> buckets[kind]?.takeIf { it.isNotEmpty() } ?.let { WidgetRun("due:${kind?.name.orEmpty()}", dueBucketLabel(kind), it) } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetStats.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetStats.kt index e048b2a..14b85b1 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetStats.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetStats.kt @@ -7,9 +7,11 @@ import de.jeanlucmakiola.agendula.data.prefs.is24Hour import de.jeanlucmakiola.agendula.domain.SmartList import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskFilter +import de.jeanlucmakiola.agendula.domain.TaskFiltering import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.agendula.domain.calendarDate import de.jeanlucmakiola.agendula.domain.comparator +import de.jeanlucmakiola.floret.time.DayWindow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine @@ -45,10 +47,15 @@ internal sealed interface WidgetStatsData { private const val MAX_TODAY_ROWS = 20 +/** Whether this open task is on the Today widget's plate: in the app's Today, or overdue. */ +private fun Task.isDueToday(now: Instant, today: LocalDate, todayStart: Instant, todayEnd: Instant, zone: ZoneId): Boolean = + TaskFiltering.isToday(this, todayStart, todayEnd, zone) || + due?.let { isOverdue(it, isAllDay, now, today, zone) } == true + /** * How far through today a set of [open] and [completed] tasks says you are: - * tasks done today (by [Task.completedAt]'s local date), tasks still open that - * are overdue or due today (in [comparator] order), and those tasks as rows. + * the app's Today tasks done and still open ([TaskFiltering.isToday]) plus the + * overdue ones, the open ones in [comparator] order, and those tasks as rows. */ internal fun todayProgress( open: List, @@ -58,14 +65,9 @@ internal fun todayProgress( today: LocalDate, zone: ZoneId, ): Triple> { - val done = completed.count { task -> - val at = task.completedAt ?: return@count false - at.toLocalDate(zone) == today - } - val remaining = open.filter { task -> - val due = task.due ?: return@filter false - isOverdue(due, task.isAllDay, now, today, zone) || dueKind(due, task.isAllDay, today, zone) == DueKind.TODAY - }.sortedWith(comparator) + val (todayStart, todayEnd) = DayWindow.today(now, zone) + val done = completed.count { TaskFiltering.isToday(it, todayStart, todayEnd, zone) } + val remaining = open.filter { it.isDueToday(now, today, todayStart, todayEnd, zone) }.sortedWith(comparator) return Triple(done, remaining.size, remaining.take(MAX_TODAY_ROWS).map { it.toWidgetRow(now, today, zone) }) } @@ -76,29 +78,29 @@ internal fun upcoming(open: List, now: Instant, today: LocalDate, zone: Zo private const val MAX_UPCOMING_ROWS = 200 /** - * Seven [WeekDay]s starting at [today]. Today's bucket folds in every overdue - * task regardless of its own due day, since that's where the app's own - * Overdue/Today smart lists put it; every other day matches its own due date. + * Seven [WeekDay]s starting at [today]. Today's bucket holds what the Today + * widget counts — the app's Today tasks plus every overdue one; every other day + * holds the rest by their own due date. */ -internal fun weekDays(open: List, comparator: Comparator, now: Instant, today: LocalDate, zone: ZoneId): List = - (0..6L).map { offset -> +internal fun weekDays(open: List, comparator: Comparator, now: Instant, today: LocalDate, zone: ZoneId): List { + val (todayStart, todayEnd) = DayWindow.today(now, zone) + val (todays, later) = open.partition { it.isDueToday(now, today, todayStart, todayEnd, zone) } + return (0..6L).map { offset -> val date = today.plusDays(offset) - val dayTasks = open.filter { task -> - val due = task.due ?: return@filter false - if (offset == 0L) { - isOverdue(due, task.isAllDay, now, today, zone) || due.calendarDate(task.isAllDay, zone) == date - } else { - due.calendarDate(task.isAllDay, zone) == date - } + val dayTasks = if (offset == 0L) { + todays + } else { + later.filter { task -> task.due?.calendarDate(task.isAllDay, zone) == date } } val overdueCount = if (offset == 0L) { - dayTasks.count { isOverdue(it.due!!, it.isAllDay, now, today, zone) } + dayTasks.count { task -> task.due?.let { isOverdue(it, task.isAllDay, now, today, zone) } == true } } else { 0 } val first = dayTasks.sortedWith(comparator).firstOrNull()?.toWidgetRow(now, today, zone) WeekDay(date, dayTasks.size, overdueCount, first) } +} /** One tile per visible list: its open, non-subtask count — a subtask whose parent is also open is left out. */ internal fun listTiles(open: List, lists: List): List { diff --git a/app/src/main/res/values-night-v31/colors.xml b/app/src/main/res/values-night-v31/colors.xml new file mode 100644 index 0000000..4e73890 --- /dev/null +++ b/app/src/main/res/values-night-v31/colors.xml @@ -0,0 +1,4 @@ + + + @android:color/system_neutral2_900 + diff --git a/app/src/main/res/values-night-v34/colors.xml b/app/src/main/res/values-night-v34/colors.xml new file mode 100644 index 0000000..c157c54 --- /dev/null +++ b/app/src/main/res/values-night-v34/colors.xml @@ -0,0 +1,3 @@ + + @android:color/system_background_dark + diff --git a/app/src/main/res/values-night/bools.xml b/app/src/main/res/values-night/bools.xml new file mode 100644 index 0000000..8497ec7 --- /dev/null +++ b/app/src/main/res/values-night/bools.xml @@ -0,0 +1,3 @@ + + false + diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml new file mode 100644 index 0000000..2c9d306 --- /dev/null +++ b/app/src/main/res/values-night/colors.xml @@ -0,0 +1,3 @@ + + #FF161113 + diff --git a/app/src/main/res/values-v31/colors.xml b/app/src/main/res/values-v31/colors.xml new file mode 100644 index 0000000..29bb018 --- /dev/null +++ b/app/src/main/res/values-v31/colors.xml @@ -0,0 +1,4 @@ + + + @android:color/system_neutral2_10 + diff --git a/app/src/main/res/values-v34/colors.xml b/app/src/main/res/values-v34/colors.xml new file mode 100644 index 0000000..53d58ea --- /dev/null +++ b/app/src/main/res/values-v34/colors.xml @@ -0,0 +1,3 @@ + + @android:color/system_background_light + diff --git a/app/src/main/res/values/bools.xml b/app/src/main/res/values/bools.xml new file mode 100644 index 0000000..0656e05 --- /dev/null +++ b/app/src/main/res/values/bools.xml @@ -0,0 +1,3 @@ + + true + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 19d27a8..0f937f6 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -2,4 +2,8 @@ #FF7A5C6B + + #FFFEFBFC + + @color/window_background_fallback diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3a5dfb7..eb37419 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,5 +1,6 @@ Agendula + Tasks Untitled task @@ -448,6 +449,10 @@ Follow system Light Dark + Black + Dark with pure black backgrounds, for AMOLED screens + App name + 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. Dynamic colour Use colours from your wallpaper Notifications @@ -681,6 +686,11 @@ Also deletes this account\u2019s lists and tasks from this device. Never synced Last sync + Name + How this account is shown in Agendula. Leave it empty to use “%1$s”. + Connection + Unencrypted (http) + Trusted certificate CalDAV account Last sync didn\u2019t finish Sync failed \u2014 the server didn\u2019t accept the password @@ -730,6 +740,7 @@ That address is unencrypted. Agendula only sends your password over https. Signed in, but this account has no task lists. The server ran into a problem. Try again in a moment. + Couldn\u2019t set up a secure connection to that server. Looking for a CalDAV server Signing in Reading your task lists @@ -808,6 +819,21 @@ The server sent us to %1$s, but you typed %2$s. That usually means its overwrite.cli.url setting is wrong. 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. + Connect without encryption? + 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. + 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. + Connect anyway + Change the address + + Trust this certificate? + 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. + SHA-256 fingerprint + Issued to + Issued by + Valid until + Trust this certificate + Don\u2019t trust it + Which lists should sync? Read-only Shared with you diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 49cb7b6..adf75b6 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,7 +1,19 @@ - + + + + + diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml index 555f452..0efb234 100644 --- a/app/src/main/res/xml/locales_config.xml +++ b/app/src/main/res/xml/locales_config.xml @@ -10,4 +10,8 @@ + + + + diff --git a/app/src/offline/java/de/jeanlucmakiola/agendula/data/di/SyncModule.kt b/app/src/offline/java/de/jeanlucmakiola/agendula/data/di/SyncModule.kt index 1a3d7fc..c796b4b 100644 --- a/app/src/offline/java/de/jeanlucmakiola/agendula/data/di/SyncModule.kt +++ b/app/src/offline/java/de/jeanlucmakiola/agendula/data/di/SyncModule.kt @@ -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 { diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/appname/LauncherNameManagerTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/appname/LauncherNameManagerTest.kt new file mode 100644 index 0000000..ae3d35d --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/appname/LauncherNameManagerTest.kt @@ -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() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/reminders/ExpandedTextTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/reminders/ExpandedTextTest.kt new file mode 100644 index 0000000..4ca7d65 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/reminders/ExpandedTextTest.kt @@ -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") + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/reminders/OccurrenceLookupTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/reminders/OccurrenceLookupTest.kt new file mode 100644 index 0000000..67d3876 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/reminders/OccurrenceLookupTest.kt @@ -0,0 +1,44 @@ +package de.jeanlucmakiola.agendula.data.reminders + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource +import de.jeanlucmakiola.agendula.domain.Task +import de.jeanlucmakiola.agendula.domain.TaskStatus +import de.jeanlucmakiola.agendula.domain.testTask +import org.junit.jupiter.api.Test +import java.lang.reflect.Proxy +import kotlin.time.Instant + +class OccurrenceLookupTest { + + private val yesterday = Instant.fromEpochMilliseconds(1_800_000_000_000) + private val today = Instant.fromEpochMilliseconds(1_800_000_000_000 + 86_400_000) + private val current = testTask(id = 10).copy(seriesId = 10, occurrenceStart = today) + + /** Answers only the two lookups [occurrence] makes. */ + private fun source(byAnchor: (Instant?) -> Task?, list: List = emptyList()): TasksDataSource = + Proxy.newProxyInstance(TasksDataSource::class.java.classLoader, arrayOf(TasksDataSource::class.java)) { _, method, args -> + when { + method.name == "task" && args?.size == 2 -> byAnchor(args[1] as Instant?) + method.name == "tasks" -> list + else -> throw UnsupportedOperationException(method.name) + } + } as TasksDataSource + + @Test + fun `a ticked occurrence is found although it is a row of its own`() { + val ticked = testTask(id = 57, status = TaskStatus.COMPLETED, completedAt = today) + .copy(seriesId = 10, occurrenceStart = yesterday) + val found = source({ if (it == yesterday) ticked else current }).occurrence(10, yesterday.toEpochMilliseconds()) + assertThat(found).isEqualTo(ticked) + assertThat(reminderIsStale(yesterday.toEpochMilliseconds(), found, missingMeansGone = true)).isTrue() + } + + @Test + fun `an exception row the anchored lookup misses is found in the list`() { + val exception = testTask(id = 99, status = TaskStatus.COMPLETED, completedAt = today) + .copy(seriesId = 10, occurrenceStart = yesterday) + val found = source({ current }, listOf(current, exception)).occurrence(10, yesterday.toEpochMilliseconds()) + assertThat(found).isEqualTo(exception) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/reminders/ReminderDiffTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/reminders/ReminderDiffTest.kt index f2cd388..d27256a 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/data/reminders/ReminderDiffTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/reminders/ReminderDiffTest.kt @@ -44,6 +44,14 @@ class ReminderDiffTest { assertThat(diff.keep).containsExactly(a) } + @Test + fun `a reopened task within the grace does not ring again`() { + // Ticked, so the store dropped it; undone, so it is wanted again. + val diff = ReminderDiff.of(previous = emptySet(), desired = setOf(a), fired = setOf(a)) { false } + assertThat(diff.arm).isEmpty() + assertThat(diff.keep).containsExactly(a) + } + @Test fun `store entries round-trip, and junk is skipped`() { val encoded = ScheduledReminderStore.encode(setOf(a, b)) diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/reminders/ReminderIsStaleTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/reminders/ReminderIsStaleTest.kt new file mode 100644 index 0000000..a59ef5f --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/reminders/ReminderIsStaleTest.kt @@ -0,0 +1,54 @@ +package de.jeanlucmakiola.agendula.data.reminders + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.agendula.domain.TaskStatus +import de.jeanlucmakiola.agendula.domain.testTask +import org.junit.jupiter.api.Test +import kotlin.time.Instant + +class ReminderIsStaleTest { + + private val none = ScheduledReminder.NO_OCCURRENCE + private val anchor = Instant.fromEpochMilliseconds(1_800_000_000_000) + + @Test + fun `a deleted task's reminder comes down`() { + assertThat(reminderIsStale(none, null, missingMeansGone = true)).isTrue() + } + + @Test + fun `a completed task's reminder comes down`() { + val done = testTask(status = TaskStatus.COMPLETED, completedAt = anchor) + assertThat(reminderIsStale(none, done, missingMeansGone = true)).isTrue() + } + + @Test + fun `an open task's reminder stays`() { + assertThat(reminderIsStale(none, testTask(), missingMeansGone = true)).isFalse() + } + + @Test + fun `a completed occurrence's reminder comes down`() { + val done = testTask(status = TaskStatus.COMPLETED, completedAt = anchor).copy(occurrenceStart = anchor) + assertThat(reminderIsStale(anchor.toEpochMilliseconds(), done, missingMeansGone = true)).isTrue() + } + + @Test + fun `a lookup that fell back to another occurrence leaves the reminder up`() { + val other = testTask(status = TaskStatus.COMPLETED, completedAt = anchor) + .copy(occurrenceStart = Instant.fromEpochMilliseconds(anchor.toEpochMilliseconds() + 86_400_000)) + assertThat(reminderIsStale(anchor.toEpochMilliseconds(), other, missingMeansGone = true)).isFalse() + } + + @Test + fun `a failed provider read leaves the reminder up`() { + assertThat(reminderIsStale(none, null, missingMeansGone = false)).isFalse() + } + + @Test + fun `a reminder from before occurrences were recorded comes down only for a task that does not recur`() { + val done = testTask(status = TaskStatus.COMPLETED, completedAt = anchor) + assertThat(reminderIsStale(null, done, missingMeansGone = true)).isTrue() + assertThat(reminderIsStale(null, done.copy(occurrenceStart = anchor), missingMeansGone = true)).isFalse() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/StoredRemindersTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/StoredRemindersTest.kt index ed6392c..78ea5ce 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/StoredRemindersTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/StoredRemindersTest.kt @@ -61,6 +61,77 @@ class StoredRemindersTest { assertThat(stored(form.copy(reminders = listOf(hourBeforeDue)), due)).containsExactly(hourBeforeDue, dayBeforeStart) } + private fun moved( + form: TaskForm, + start: Instant?, + due: Instant?, + wasAllDay: Boolean = form.isAllDay, + at: ZoneId = zone, + ) = StoredReminders.withDefaults(form, start, due, wasAllDay, settings, now.toEpochMilliseconds(), at) + + @Test + fun `a date moved after its defaults rang brings them`() { + val form = TaskForm(title = "t", listId = 1, start = now + 2.days, due = now + 5.days, reminders = listOf(hourBeforeDue)) + assertThat(moved(form, start = now, due = now + 5.days)).containsExactly(hourBeforeDue, dayBeforeStart) + // Its date still ahead, but the lead already past. + assertThat(moved(form, start = now + 2.hours, due = now + 5.days)).containsExactly(hourBeforeDue, dayBeforeStart) + } + + @Test + fun `a moved due brings its defaults the same way`() { + val ownStart = TaskReminder(0, fromStart = true) + val form = TaskForm(title = "t", listId = 1, start = now - 3.days, due = now + 5.days, reminders = listOf(ownStart)) + assertThat(moved(form, start = now - 3.days, due = now)).containsExactly(ownStart, dayBeforeDue, hourBeforeDue) + } + + @Test + fun `a moved date keeps the form when its defaults had not rung or are named`() { + val form = TaskForm(title = "t", listId = 1, start = now + 3.days, due = now + 5.days, reminders = listOf(hourBeforeDue)) + assertThat(moved(form, start = now + 2.days, due = now + 5.days)).containsExactly(hourBeforeDue) + val ownStart = TaskReminder(30, fromStart = true) + assertThat(moved(form.copy(reminders = listOf(ownStart)), start = now, due = now + 5.days)).containsExactly(ownStart) + } + + @Test + fun `a task without reminders of its own keeps none when a past date moves`() { + val form = TaskForm(title = "t", listId = 1, start = now + 2.days, due = now + 5.days) + assertThat(moved(form, start = now, due = now + 5.days)).isEmpty() + assertThat(moved(form.copy(rrule = "FREQ=DAILY", reminders = listOf(hourBeforeDue)), start = now, due = now + 5.days)) + .containsExactly(hourBeforeDue) + } + + @Test + fun `turning all-day without moving the date is not a move`() { + val form = TaskForm( + title = "t", + listId = 1, + start = allDayInstantOf(LocalDate.of(2026, 10, 1)), + due = allDayInstantOf(LocalDate.of(2026, 10, 6)), + isAllDay = true, + reminders = listOf(TaskReminder(1_440)), + ) + assertThat(moved(form, start = now, due = now + 5.days, wasAllDay = false)).containsExactly(TaskReminder(1_440)) + } + + @Test + fun `an all-day date is past by its own calendar, not by UTC midnight`() { + val ownStart = TaskReminder(0, fromStart = true) + val newYork = ZoneId.of("America/New_York") + // 20:00 on Sep 30 in New York: Oct 1 has not begun there. + val form = TaskForm( + title = "t", + listId = 1, + start = allDayInstantOf(LocalDate.of(2026, 9, 28)), + due = allDayInstantOf(LocalDate.of(2026, 10, 2)), + isAllDay = true, + reminders = listOf(ownStart), + ) + val due = allDayInstantOf(LocalDate.of(2026, 10, 1)) + assertThat( + StoredReminders.withDefaults(form, form.start, due, true, settings, Instant.parse("2026-10-01T00:00:00Z").toEpochMilliseconds(), newYork), + ).containsExactly(ownStart) + } + @Test fun `a gained date the form already counts from keeps the form`() { val current = testTask(start = now + 2.days) diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapperTest.kt index ea54286..83ef2a6 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapperTest.kt @@ -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") + } } diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskFilteringTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskFilteringTest.kt index 7749331..d536791 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskFilteringTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskFilteringTest.kt @@ -45,11 +45,36 @@ class TaskFilteringTest { } @Test - fun `completed only matches COMPLETED, never the open lists`() { + fun `completed matches COMPLETED, never the open lists`() { val task = testTask(status = TaskStatus.COMPLETED, due = at(1000 * dayMs + 1)) assertThat(matches(task, SmartList.COMPLETED)).isTrue() assertThat(matches(task, SmartList.ALL)).isFalse() + assertThat(matches(task, SmartList.UPCOMING)).isFalse() + } + + @Test + fun `today takes a task that starts today`() { + assertThat(matches(testTask(start = at(1000 * dayMs + 1)), SmartList.TODAY)).isTrue() + assertThat(matches(testTask(start = at(1000 * dayMs + 1), due = at(1003 * dayMs)), SmartList.TODAY)).isTrue() + assertThat(matches(testTask(start = at(1001 * dayMs + 1)), SmartList.TODAY)).isFalse() + assertThat(matches(testTask(start = at(999 * dayMs)), SmartList.TODAY)).isFalse() + } + + @Test + fun `a task starting today but already overdue stays in Overdue only`() { + val task = testTask(start = at(1000 * dayMs + 1), due = at(999 * dayMs)) assertThat(matches(task, SmartList.TODAY)).isFalse() + assertThat(matches(task, SmartList.OVERDUE)).isTrue() + } + + @Test + fun `today takes a task completed today, whatever its dates`() { + val done = testTask(status = TaskStatus.COMPLETED, due = at(990 * dayMs), completedAt = at(1000 * dayMs + 5)) + assertThat(matches(done, SmartList.TODAY)).isTrue() + val dueTodayDoneEarlier = done.copy(due = at(1000 * dayMs + 1), completedAt = at(999 * dayMs)) + assertThat(matches(dueTodayDoneEarlier, SmartList.TODAY)).isFalse() + val cancelled = testTask(status = TaskStatus.CANCELLED, due = at(1000 * dayMs + 1)) + assertThat(matches(cancelled, SmartList.TODAY)).isFalse() } @Test diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskSectionsTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskSectionsTest.kt index fc0e5f9..6aad103 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskSectionsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskSectionsTest.kt @@ -40,6 +40,14 @@ class TaskSectionsTest { .inOrder() } + @Test + fun `the Today list keeps open tasks under Today`() { + fun inToday(task: Task) = TaskSections.sectionOf(task, todayStart, todayEnd, todayList = true) + assertThat(inToday(testTask(start = at(1000 * dayMs + 1)))).isEqualTo(TaskSection.TODAY) + assertThat(inToday(testTask(start = at(1000 * dayMs + 1), due = at(1003 * dayMs)))).isEqualTo(TaskSection.TODAY) + assertThat(inToday(testTask(status = TaskStatus.COMPLETED))).isEqualTo(TaskSection.COMPLETED) + } + @Test fun `empty input yields no sections`() { assertThat(TaskSections.of(emptyList(), todayStart, todayEnd)).isEmpty() diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/ui/theme/SystemThemeTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/ui/theme/SystemThemeTest.kt new file mode 100644 index 0000000..584b0b9 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/ui/theme/SystemThemeTest.kt @@ -0,0 +1,26 @@ +package de.jeanlucmakiola.agendula.ui.theme + +import android.app.UiModeManager +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.data.prefs.ThemeMode +import org.junit.jupiter.api.Test + +class SystemThemeTest { + + @Test + fun `the system follows the app's light or dark`() { + assertThat(SystemTheme.nightModeOf(ThemeMode.SYSTEM)).isEqualTo(UiModeManager.MODE_NIGHT_AUTO) + assertThat(SystemTheme.nightModeOf(ThemeMode.LIGHT)).isEqualTo(UiModeManager.MODE_NIGHT_NO) + assertThat(SystemTheme.nightModeOf(ThemeMode.DARK)).isEqualTo(UiModeManager.MODE_NIGHT_YES) + assertThat(SystemTheme.nightModeOf(ThemeMode.BLACK)).isEqualTo(UiModeManager.MODE_NIGHT_YES) + } + + @Test + fun `the splash opens on the background the app draws`() { + assertThat(SystemTheme.splashThemeOf(ThemeMode.SYSTEM, dynamicColor = true)).isEqualTo(R.style.Theme_Agendula) + assertThat(SystemTheme.splashThemeOf(ThemeMode.DARK, dynamicColor = false)).isEqualTo(R.style.Theme_Agendula_Fallback) + assertThat(SystemTheme.splashThemeOf(ThemeMode.BLACK, dynamicColor = true)).isEqualTo(R.style.Theme_Agendula_Black) + assertThat(SystemTheme.splashThemeOf(ThemeMode.BLACK, dynamicColor = false)).isEqualTo(R.style.Theme_Agendula_Black) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/ui/theme/WindowColorsTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/ui/theme/WindowColorsTest.kt new file mode 100644 index 0000000..8c40484 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/ui/theme/WindowColorsTest.kt @@ -0,0 +1,27 @@ +package de.jeanlucmakiola.agendula.ui.theme + +import androidx.compose.ui.graphics.toArgb +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import java.io.File +import javax.xml.parsers.DocumentBuilderFactory + +/** The launch window's colour has to be the background Compose then draws, or the splash flashes. */ +class WindowColorsTest { + + private fun fallbackIn(qualifier: String): Int { + val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder() + .parse(File("src/main/res/$qualifier/colors.xml")) + val colors = doc.getElementsByTagName("color") + val hex = (0 until colors.length).map { colors.item(it) } + .single { it.attributes.getNamedItem("name").nodeValue == "window_background_fallback" } + .textContent.trim().removePrefix("#") + return hex.toLong(16).toInt() + } + + @Test + fun `the window matches the fallback schemes`() { + assertThat(fallbackIn("values")).isEqualTo(AgendulaLightFallback.background.toArgb()) + assertThat(fallbackIn("values-night")).isEqualTo(AgendulaDarkFallback.background.toArgb()) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetDataTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetDataTest.kt index 1951ca0..8d5dfd6 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetDataTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetDataTest.kt @@ -171,6 +171,13 @@ class WidgetDataTest { assertThat(sections.filterIsInstance().map { it.row.taskId }).containsExactly(1L, 2L, 3L).inOrder() } + @Test + fun `the Today list is one Today bucket whatever the rows' due days`() { + val rows = listOf(row(1, dueKind = DueKind.TODAY), row(2, dueKind = DueKind.LATER), row(3, dueKind = null)) + val sections = widgetSections(rows, groupByList = false, listNames = emptyMap(), todayList = true) { it?.name ?: "none" } + assertThat(sections.filterIsInstance().map { it.key }).containsExactly("due:TODAY") + } + @Test fun `grouping by list keeps the widgetRows order and headers each run once`() { val rows = listOf(row(1, listId = 1), row(2, listId = 1), row(3, listId = 2)) @@ -210,6 +217,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 diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetStatsTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetStatsTest.kt index db25657..019d4d7 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetStatsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetStatsTest.kt @@ -47,6 +47,37 @@ class WidgetStatsTest { assertThat(remaining).isEqualTo(2) } + @Test + fun `a task starting today counts as remaining`() { + val open = listOf( + testTask(id = 1, start = at("2026-07-20T16:00:00")), + testTask(id = 2, start = at("2026-07-20T09:00:00"), due = at("2026-07-23T09:00:00")), + testTask(id = 3, start = at("2026-07-21T09:00:00")), + ) + val (_, remaining, _) = todayProgress(open, emptyList(), comparator, now, today, newYork) + assertThat(remaining).isEqualTo(2) + } + + @Test + fun `a task due today but completed yesterday is not done today`() { + val completed = listOf( + testTask(id = 1, status = TaskStatus.COMPLETED, due = at("2026-07-20T18:00:00"), completedAt = at("2026-07-19T20:00:00")), + ) + val (done, _, _) = todayProgress(emptyList(), completed, comparator, now, today, newYork) + assertThat(done).isEqualTo(0) + } + + @Test + fun `the week's today holds a task starting today, and not again on its due day`() { + val open = listOf( + testTask(id = 1, start = at("2026-07-20T09:00:00"), due = at("2026-07-22T09:00:00")), + testTask(id = 2, start = at("2026-07-20T16:00:00")), + ) + val week = weekDays(open, comparator, now, today, newYork) + assertThat(week[0].count).isEqualTo(2) + assertThat(week[2].count).isEqualTo(0) + } + @Test fun `an earlier-due remaining task sorts ahead of a later one`() { val open = listOf( diff --git a/app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/HttpClientConstructionTest.kt b/app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/HttpClientConstructionTest.kt new file mode 100644 index 0000000..79c1ecf --- /dev/null +++ b/app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/HttpClientConstructionTest.kt @@ -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() + } +} diff --git a/app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/ServerTrustStoreTest.kt b/app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/ServerTrustStoreTest.kt new file mode 100644 index 0000000..ab5911c --- /dev/null +++ b/app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/ServerTrustStoreTest.kt @@ -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 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() + } +} diff --git a/app/src/testFull/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModelTest.kt b/app/src/testFull/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModelTest.kt index ee123ff..622f82d 100644 --- a/app/src/testFull/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModelTest.kt +++ b/app/src/testFull/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModelTest.kt @@ -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() val discoveryOutcomes = ArrayDeque() @@ -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() @@ -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() 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() var thrown: Throwable? = null var outcome: AccountRepository.Outcome? = null + val savedTrust = mutableListOf() + val storedTrust = mutableMapOf() + + 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, reauthenticating: Long?, + trust: ServerTrust, ): AccountRepository.Outcome { reauthenticated += reauthenticating + savedTrust += trust thrown?.let { throw it } outcome?.let { return it } created += displayName diff --git a/caldav/build.gradle.kts b/caldav/build.gradle.kts index e159e77..dcbe08d 100644 --- a/caldav/build.gradle.kts +++ b/caldav/build.gradle.kts @@ -32,5 +32,6 @@ dependencies { testImplementation(libs.xpp3) testImplementation(libs.junit4) testImplementation(libs.okhttp.mockwebserver) + testImplementation(libs.okhttp.tls) testImplementation(libs.truth) } diff --git a/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/CalDavDiscovery.kt b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/CalDavDiscovery.kt index 0854440..ec5e0e0 100644 --- a/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/CalDavDiscovery.kt +++ b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/CalDavDiscovery.kt @@ -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() 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 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 diff --git a/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/CalDavHttp.kt b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/CalDavHttp.kt index 0ff1ad1..9c0bdbd 100644 --- a/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/CalDavHttp.kt +++ b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/CalDavHttp.kt @@ -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) { + 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, Tls>(TLS_CACHE_SIZE, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry, 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. diff --git a/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/PinningTrustManager.kt b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/PinningTrustManager.kt new file mode 100644 index 0000000..013d783 --- /dev/null +++ b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/PinningTrustManager.kt @@ -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, authType: String) = + pinnedOr(chain) { platform.checkServerTrusted(chain, authType) } + + override fun checkServerTrusted(chain: Array, 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, 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, authType: String) = + platform.checkClientTrusted(chain, authType) + + override fun checkClientTrusted(chain: Array, authType: String, socket: Socket?) = + if (platform is X509ExtendedTrustManager) { + platform.checkClientTrusted(chain, authType, socket) + } else { + platform.checkClientTrusted(chain, authType) + } + + override fun checkClientTrusted(chain: Array, authType: String, engine: SSLEngine?) = + if (platform is X509ExtendedTrustManager) { + platform.checkClientTrusted(chain, authType, engine) + } else { + platform.checkClientTrusted(chain, authType) + } + + override fun getAcceptedIssuers(): Array = platform.acceptedIssuers + + private inline fun pinnedOr(chain: Array, 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().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) + } +} diff --git a/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/ServerTrust.kt b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/ServerTrust.kt new file mode 100644 index 0000000..0164d9b --- /dev/null +++ b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/ServerTrust.kt @@ -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 = 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() + while (current != null && seen.add(current)) { + when (current) { + is UntrustedCertificateException -> return current.certificate + is UntrustedHostException -> return current.certificate + } + current = current.cause + } + return null +} diff --git a/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/ServiceDiscovery.kt b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/ServiceDiscovery.kt index 4ed5a22..b6e1fc3 100644 --- a/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/ServiceDiscovery.kt +++ b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/ServiceDiscovery.kt @@ -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, origin: String, path: String, label: String) { + private fun add( + into: MutableList, + 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) } /** diff --git a/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/CalendarCollectionTest.kt b/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/CalendarCollectionTest.kt index 5f53406..f740f7c 100644 --- a/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/CalendarCollectionTest.kt +++ b/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/CalendarCollectionTest.kt @@ -504,7 +504,7 @@ END:VCALENDAR // --------------------------------------------------------------- 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 } @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 } @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( diff --git a/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/CollectionAdminTest.kt b/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/CollectionAdminTest.kt index 7bba58d..dce7577 100644 --- a/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/CollectionAdminTest.kt +++ b/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/CollectionAdminTest.kt @@ -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() diff --git a/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/ServerTrustTest.kt b/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/ServerTrustTest.kt new file mode 100644 index 0000000..9e2762f --- /dev/null +++ b/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/ServerTrustTest.kt @@ -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() + } +} diff --git a/fastlane/metadata/android/ar/changelogs/1030099.txt b/fastlane/metadata/android/ar/changelogs/1030099.txt new file mode 100644 index 0000000..a13b5ba --- /dev/null +++ b/fastlane/metadata/android/ar/changelogs/1030099.txt @@ -0,0 +1,13 @@ +### جديد +- خوادم ذاتية الاستضافة بشهادة موقّعة ذاتيًا أو عبر http. +- تسمية الحسابات، والسحب للمزامنة، وطيّ أقسام القائمة. +- سمة سوداء خالصة لشاشات AMOLED. +- يمكن أن يظهر Agendula باسم "Tasks" في المشغّل. +- تعرض التذكيرات وصف المهمة. + +### تغييرات +- يعرض "اليوم" أيضًا المهام التي تبدأ اليوم ويُبقي ما أُنجز اليوم. + +### إصلاحات +- إنجاز المهمة من أي مكان يلغي تذكيرها. +- شاشة البداية تتبع سمة التطبيق. diff --git a/fastlane/metadata/android/cs-CZ/changelogs/1030099.txt b/fastlane/metadata/android/cs-CZ/changelogs/1030099.txt new file mode 100644 index 0000000..6603ac2 --- /dev/null +++ b/fastlane/metadata/android/cs-CZ/changelogs/1030099.txt @@ -0,0 +1,13 @@ +### Nové +- Vlastní servery s certifikátem podepsaným sebou samým nebo přes http. +- Pojmenování účtů, synchronizace tažením, sbalitelné sekce seznamu. +- Čistě černý motiv pro displeje AMOLED. +- Agendula se může v launcheru jmenovat „Tasks“. +- Připomínky zobrazují popis úkolu. + +### Změny +- Dnes ukazuje i úkoly začínající dnes a ponechá dnes dokončené. + +### Opravy +- Dokončení úkolu kdekoli zruší jeho připomínku. +- Úvodní obrazovka sleduje motiv aplikace. diff --git a/fastlane/metadata/android/de-DE/changelogs/1030099.txt b/fastlane/metadata/android/de-DE/changelogs/1030099.txt new file mode 100644 index 0000000..d84b38e --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/1030099.txt @@ -0,0 +1,13 @@ +### Neu +- Eigene Server mit selbstsigniertem Zertifikat oder über http. +- Konten benennen, zum Synchronisieren ziehen, Listenabschnitte einklappen. +- Ein reines Schwarz-Design für AMOLED-Displays. +- Agendula kann im Launcher als „Tasks“ erscheinen. +- Erinnerungen zeigen die Beschreibung der Aufgabe. + +### Geändert +- Heute zeigt auch heute beginnende und heute erledigte Aufgaben. + +### Behoben +- Abhaken, egal wo, beendet die Erinnerung der Aufgabe. +- Der Startbildschirm folgt dem App-Design. diff --git a/fastlane/metadata/android/en-GB/changelogs/1030099.txt b/fastlane/metadata/android/en-GB/changelogs/1030099.txt new file mode 100644 index 0000000..5834980 --- /dev/null +++ b/fastlane/metadata/android/en-GB/changelogs/1030099.txt @@ -0,0 +1,13 @@ +### Added +- Self-hosted servers with a self-signed certificate or plain http. +- Name your accounts, pull to sync and collapse list sections. +- A pure black theme for AMOLED screens. +- Agendula can show up as "Tasks" in your launcher. +- Reminders show the task's description. + +### Changed +- Today also lists tasks starting today and keeps today's completions. + +### Fixed +- Completing a task anywhere cancels its reminder. +- The splash screen follows the app theme. diff --git a/fastlane/metadata/android/en-US/changelogs/1030099.txt b/fastlane/metadata/android/en-US/changelogs/1030099.txt new file mode 100644 index 0000000..5834980 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/1030099.txt @@ -0,0 +1,13 @@ +### Added +- Self-hosted servers with a self-signed certificate or plain http. +- Name your accounts, pull to sync and collapse list sections. +- A pure black theme for AMOLED screens. +- Agendula can show up as "Tasks" in your launcher. +- Reminders show the task's description. + +### Changed +- Today also lists tasks starting today and keeps today's completions. + +### Fixed +- Completing a task anywhere cancels its reminder. +- The splash screen follows the app theme. diff --git a/fastlane/metadata/android/fr-FR/changelogs/1030099.txt b/fastlane/metadata/android/fr-FR/changelogs/1030099.txt new file mode 100644 index 0000000..5fb1a47 --- /dev/null +++ b/fastlane/metadata/android/fr-FR/changelogs/1030099.txt @@ -0,0 +1,13 @@ +### Nouveautés +- Serveurs perso avec certificat auto-signé ou en http. +- Nommer les comptes, tirer pour synchroniser, replier les sections. +- Un thème noir pur pour les écrans AMOLED. +- Agendula peut s'afficher « Tasks » dans le lanceur. +- Les rappels affichent la description de la tâche. + +### Modifications +- Aujourd'hui montre aussi les tâches débutées ou finies du jour. + +### Corrections +- Cocher une tâche, où que ce soit, annule son rappel. +- L'écran de démarrage suit le thème de l'appli. diff --git a/fastlane/metadata/android/pt-BR/changelogs/1030099.txt b/fastlane/metadata/android/pt-BR/changelogs/1030099.txt new file mode 100644 index 0000000..d68cf28 --- /dev/null +++ b/fastlane/metadata/android/pt-BR/changelogs/1030099.txt @@ -0,0 +1,13 @@ +### Novidades +- Servidores próprios com certificado autoassinado ou via http. +- Dê nome às contas, puxe para sincronizar e recolha seções da lista. +- Um tema preto puro para telas AMOLED. +- O Agendula pode aparecer como "Tasks" no launcher. +- Lembretes mostram a descrição da tarefa. + +### Alterações +- Hoje também lista tarefas que começam hoje e mantém as concluídas hoje. + +### Correções +- Concluir uma tarefa em qualquer lugar cancela o lembrete. +- A tela de abertura segue o tema do app. diff --git a/fastlane/metadata/android/ru-RU/changelogs/1030099.txt b/fastlane/metadata/android/ru-RU/changelogs/1030099.txt new file mode 100644 index 0000000..fa22079 --- /dev/null +++ b/fastlane/metadata/android/ru-RU/changelogs/1030099.txt @@ -0,0 +1,13 @@ +### Новое +- Свои серверы с самоподписанным сертификатом или по http. +- Имена для аккаунтов, синхронизация жестом вниз, сворачиваемые разделы списка. +- Чисто чёрная тема для AMOLED-экранов. +- Agendula может называться «Tasks» в лаунчере. +- Напоминания показывают описание задачи. + +### Изменения +- «Сегодня» показывает и задачи, начинающиеся сегодня, и сохраняет выполненные сегодня. + +### Исправления +- Выполнение задачи в любом месте отменяет её напоминание. +- Заставка следует теме приложения. diff --git a/fastlane/metadata/android/ru-RU/full_description.txt b/fastlane/metadata/android/ru-RU/full_description.txt new file mode 100644 index 0000000..78353f2 --- /dev/null +++ b/fastlane/metadata/android/ru-RU/full_description.txt @@ -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-аккаунтами. Без них ваши задачи не покидают устройство, если вы их не экспортируете. diff --git a/fastlane/metadata/android/ru-RU/short_description.txt b/fastlane/metadata/android/ru-RU/short_description.txt new file mode 100644 index 0000000..315b86c --- /dev/null +++ b/fastlane/metadata/android/ru-RU/short_description.txt @@ -0,0 +1 @@ +Приложение задач в стиле Material 3 Expressive с синхронизацией CalDAV. diff --git a/fastlane/metadata/android/ru-RU/title.txt b/fastlane/metadata/android/ru-RU/title.txt new file mode 100644 index 0000000..5c368ac --- /dev/null +++ b/fastlane/metadata/android/ru-RU/title.txt @@ -0,0 +1 @@ +Agendula: Tasks diff --git a/floret-kit b/floret-kit index e8d3332..a26bea7 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit e8d33329c81c76dc72887efd2ee1a3d92045844d +Subproject commit a26bea7871431d5333228f17be1b5d22a0c34337