From 08c297f30ad54d02efebc991e22bc17bf2a559b3 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 26 Oct 2026 20:41:58 +0100 Subject: [PATCH] Release 1.2.0 (#103) Stable 1.2.0, after 1.2.0-beta.1 and beta.2. - Version bumped to 1.2.0 (1020099); the CHANGELOG entries moved from Unreleased to 1.2.0. - What's New for every shipped locale, now including cs-CZ. - Full store listing (title, short and full description) for every locale; new for cs-CZ, ar and fr-FR. Play only gets these through the `listing` lane. - `main` is merged in with the branch side kept. #81 and the Weblate updates already came in through the #88 squash, and the only lines unique to `main` were string keys that #88 removed. The #75 fix landed after beta.2, so no reporter has tested it yet. Closes #72, closes #78, closes #75, closes #92 Co-authored-by: Jean-Luc Makiola Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/103 --- .forgejo/workflows/ci.yaml | 9 + CHANGELOG.md | 53 +- README.md | 6 +- app/build.gradle.kts | 4 +- app/proguard-rules.pro | 4 +- .../provider/AgendulaTaskProviderTest.kt | 157 ++++ .../provider/ProviderSnapshotSourceTest.kt | 214 +++++ .../data/tasks/provider/ProviderWriterTest.kt | 811 ++++++++++++++++++ .../tasks/provider/SnapshotDatabaseTest.kt | 141 +++ .../tasks/room/RoomTasksDataSourceTest.kt | 101 ++- .../data/tasks/transfer/ExternalImportTest.kt | 13 +- .../agendula/data/sync/CalDavGateway.kt | 5 +- .../agendula/data/sync/CollectionSyncer.kt | 10 +- .../agendula/data/sync/SystemDnsResolver.kt | 103 +++ .../ui/accounts/AccountDetailScreen.kt | 3 +- .../agendula/ui/accounts/AccountsScreen.kt | 3 + .../ui/accounts/add/AddAccountScreen.kt | 4 +- .../ui/accounts/add/AddAccountViewModel.kt | 102 ++- .../agendula/ui/accounts/add/AddressStep.kt | 4 +- .../agendula/ui/settings/AccountSections.kt | 9 +- app/src/main/AndroidManifest.xml | 28 +- .../de/jeanlucmakiola/agendula/AgendulaApp.kt | 3 + .../agendula/data/prefs/SettingsPrefs.kt | 75 +- .../data/reminders/ReminderPlanner.kt | 59 +- .../data/tasks/AndroidTasksDataSource.kt | 69 +- .../data/tasks/ModeRoutingTasksDataSource.kt | 21 +- .../agendula/data/tasks/StoredReminders.kt | 101 +++ .../agendula/data/tasks/TasksContract.kt | 14 + .../agendula/data/tasks/TasksDataSource.kt | 81 +- .../agendula/data/tasks/TasksRepository.kt | 8 +- .../data/tasks/TasksRepositoryImpl.kt | 59 +- .../agendula/data/tasks/ical/AlarmCodec.kt | 85 +- .../tasks/provider/AgendulaTaskProvider.kt | 99 +++ .../data/tasks/provider/ProviderSchema.kt | 116 +++ .../data/tasks/provider/ProviderSnapshot.kt | 267 ++++++ .../tasks/provider/ProviderSnapshotSource.kt | 182 ++++ .../data/tasks/provider/ProviderTaskWriter.kt | 304 +++++++ .../data/tasks/provider/ProviderWriter.kt | 441 ++++++++++ .../data/tasks/provider/SnapshotDatabase.kt | 88 ++ .../data/tasks/room/RoomTasksDataSource.kt | 203 +++-- .../agendula/data/tasks/room/TaskAlarmDao.kt | 11 +- .../agendula/data/tasks/room/TaskDao.kt | 4 + .../data/tasks/room/TaskFormWriter.kt | 1 + .../data/tasks/transfer/ExternalImport.kt | 2 +- .../agendula/domain/TaskForm.kt | 10 +- .../agendula/domain/TaskReminder.kt | 85 ++ .../agendula/ui/common/ReminderFormatting.kt | 54 +- .../agendula/ui/common/ReminderLeadPicker.kt | 131 +-- .../agendula/ui/detail/TaskDetailScreen.kt | 14 +- .../agendula/ui/detail/TaskDetailViewModel.kt | 20 +- .../agendula/ui/edit/TaskEditScreen.kt | 183 +++- .../agendula/ui/edit/TaskEditViewModel.kt | 120 ++- .../agendula/ui/export/ExportScreen.kt | 3 + .../agendula/ui/imports/ImportScreen.kt | 4 + .../agendula/ui/licences/LicencesScreen.kt | 4 +- .../agendula/ui/navigation/AgendulaNavHost.kt | 22 +- .../agendula/ui/settings/AppearanceScreen.kt | 8 +- .../agendula/ui/settings/BackupScreen.kt | 37 + ...indersScreen.kt => NotificationsScreen.kt} | 55 +- .../agendula/ui/settings/SettingsScreen.kt | 186 ++-- .../agendula/ui/settings/SettingsViewModel.kt | 9 +- .../agendula/ui/settings/StorageScreen.kt | 29 +- .../agendula/ui/settings/TaskFormScreen.kt | 8 +- .../agendula/widget/TaskWidget.kt | 13 +- .../main/res/layout/widget_preview_tasks.xml | 2 +- app/src/main/res/values-ar/strings.xml | 2 - app/src/main/res/values-de/strings.xml | 1 - app/src/main/res/values-fr/strings.xml | 1 - app/src/main/res/values-pt-rBR/strings.xml | 1 - app/src/main/res/values/strings.xml | 79 +- app/src/main/res/values/widget_dimens.xml | 7 + .../data/reminders/ReminderPlannerTest.kt | 97 ++- .../data/tasks/StoredRemindersTest.kt | 116 +++ .../data/tasks/ical/AlarmCodecTest.kt | 58 +- .../data/tasks/provider/ProviderIdsTest.kt | 96 +++ .../tasks/provider/ProviderSnapshotTest.kt | 371 ++++++++ .../data/tasks/provider/ProviderTableTest.kt | 70 ++ .../tasks/provider/ProviderTaskWriterTest.kt | 490 +++++++++++ .../data/tasks/room/TaskFormWriterTest.kt | 12 + .../agendula/domain/TaskFormTest.kt | 12 +- .../agendula/domain/TaskReminderTest.kt | 60 ++ .../agendula/domain/TestTasks.kt | 6 +- .../data/sync/CollectionSyncerTest.kt | 23 +- .../accounts/add/AddAccountViewModelTest.kt | 220 +++++ .../de/jeanlucmakiola/caldav/DnsAnswers.kt | 29 + .../jeanlucmakiola/caldav/DnsJavaResolver.kt | 34 - .../jeanlucmakiola/caldav/ServiceDiscovery.kt | 35 +- .../jeanlucmakiola/caldav/DnsAnswersTest.kt | 44 + .../caldav/ServiceDiscoveryTest.kt | 12 +- docs/CONTENT_PROVIDER.md | 119 +++ docs/PRIVACY.md | 26 +- .../android/ar/changelogs/1020099.txt | 11 + .../metadata/android/ar/full_description.txt | 11 + .../metadata/android/ar/short_description.txt | 1 + fastlane/metadata/android/ar/title.txt | 1 + .../android/cs-CZ/changelogs/1020099.txt | 11 + .../android/cs-CZ/full_description.txt | 11 + .../android/cs-CZ/short_description.txt | 1 + fastlane/metadata/android/cs-CZ/title.txt | 1 + .../android/de-DE/changelogs/1020099.txt | 11 + .../android/en-GB/changelogs/1020099.txt | 11 + .../android/en-US/changelogs/1020099.txt | 11 + .../android/fr-FR/changelogs/1020099.txt | 11 + .../android/fr-FR/full_description.txt | 11 + .../android/fr-FR/short_description.txt | 1 + fastlane/metadata/android/fr-FR/title.txt | 1 + .../android/pt-BR/changelogs/1020099.txt | 11 + floret-kit | 2 +- gradle/libs.versions.toml | 7 +- scripts/check_r8_keeps.py | 55 ++ 110 files changed, 6491 insertions(+), 784 deletions(-) create mode 100644 app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/provider/AgendulaTaskProviderTest.kt create mode 100644 app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderSnapshotSourceTest.kt create mode 100644 app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderWriterTest.kt create mode 100644 app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/provider/SnapshotDatabaseTest.kt create mode 100644 app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SystemDnsResolver.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StoredReminders.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/AgendulaTaskProvider.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderSchema.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderSnapshot.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderSnapshotSource.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderTaskWriter.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderWriter.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/SnapshotDatabase.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskReminder.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/BackupScreen.kt rename app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/{RemindersScreen.kt => NotificationsScreen.kt} (92%) create mode 100644 app/src/main/res/values/widget_dimens.xml create mode 100644 app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/StoredRemindersTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderIdsTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderSnapshotTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderTableTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderTaskWriterTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskReminderTest.kt create mode 100644 caldav/src/main/kotlin/de/jeanlucmakiola/caldav/DnsAnswers.kt delete mode 100644 caldav/src/main/kotlin/de/jeanlucmakiola/caldav/DnsJavaResolver.kt create mode 100644 caldav/src/test/kotlin/de/jeanlucmakiola/caldav/DnsAnswersTest.kt create mode 100644 docs/CONTENT_PROVIDER.md create mode 100644 fastlane/metadata/android/ar/changelogs/1020099.txt create mode 100644 fastlane/metadata/android/ar/full_description.txt create mode 100644 fastlane/metadata/android/ar/short_description.txt create mode 100644 fastlane/metadata/android/ar/title.txt create mode 100644 fastlane/metadata/android/cs-CZ/changelogs/1020099.txt create mode 100644 fastlane/metadata/android/cs-CZ/full_description.txt create mode 100644 fastlane/metadata/android/cs-CZ/short_description.txt create mode 100644 fastlane/metadata/android/cs-CZ/title.txt create mode 100644 fastlane/metadata/android/de-DE/changelogs/1020099.txt create mode 100644 fastlane/metadata/android/en-GB/changelogs/1020099.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/1020099.txt create mode 100644 fastlane/metadata/android/fr-FR/changelogs/1020099.txt create mode 100644 fastlane/metadata/android/fr-FR/full_description.txt create mode 100644 fastlane/metadata/android/fr-FR/short_description.txt create mode 100644 fastlane/metadata/android/fr-FR/title.txt create mode 100644 fastlane/metadata/android/pt-BR/changelogs/1020099.txt create mode 100755 scripts/check_r8_keeps.py diff --git a/.forgejo/workflows/ci.yaml b/.forgejo/workflows/ci.yaml index 6e82c5f..aa25172 100644 --- a/.forgejo/workflows/ci.yaml +++ b/.forgejo/workflows/ci.yaml @@ -195,6 +195,15 @@ jobs: if: steps.scope.outputs.code == 'true' run: ./gradlew assembleDebug + # Debug builds aren't minified, so a keep rule that drops a reflectively + # created class's constructor only breaks release builds (#1, #75). The + # releaseTest twin is R8-shrunk with the release rules and debug-signed. + - name: R8 keeps reflective constructors + if: steps.scope.outputs.code == 'true' + run: | + ./gradlew assembleFullReleaseTest + python3 scripts/check_r8_keeps.py + - name: Trivy filesystem scan if: steps.scope.outputs.code == 'true' run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ec6c85..c79ac86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,19 +7,68 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +## [1.2.0] - 2026-10-26 + +### Added +- A reminder can count from the task's start as well as from its due date, and + tasks with only a start date get reminders too. Default reminders can mix + both, e.g. a day before the start and an hour before the due date; each + applies to tasks that have its date (#72). +- An all-day task's reminders can each have their own time, so one task can + remind you at 09:00 and again at 17:00 (#78). +- Other apps can read the tasks kept in Agendula's own database through an + OpenTasks-compatible provider, once you grant them permission. Apps that + already support OpenTasks or tasks.org need only a small change to support + Agendula too (#43). +- With a second permission, other apps can also add, edit, complete and delete + those tasks, including their reminders and tags. They can tick off a single + occurrence of a recurring task. Task lists stay read-only for them (#63). + ### Changed +- A new task no longer takes a default reminder whose time has already + passed, so a start of "today" does not ring the moment the task is saved. +- All-day default reminders are whole days; one set in hours rounds to the + nearest day. +- Switching a task between timed and all-day converts its reminders right + away in the editor, so what you see is what gets saved. +- The editor shows the default reminders a new task, or a newly added date, + will get, before you save. Removing them leaves the task without a reminder + instead of falling back to the default. - Reminders now sync. A reminder set on a task is uploaded as a standard alarm (`VALARM`), so it also notifies in macOS and iOS Reminders, Thunderbird, tasks.org and jtx Board, and alarms set in those apps now fire in Agendula instead of the default reminder. Removing a task's alarm in another app turns it off here too, rather than falling back to the default (#52). -- A new task with a due date, or one that gets its first due date, takes the - default reminder as its own, so it travels with the task to the server. +- A new task with a date, or one that gets a new date, takes the default + reminder for that date as its own, so it travels with the task to the server. Changing the default later no longer changes those tasks; tasks that rely on the default today are left as they are. - On upgrade, reminders on open, synced tasks that so far stayed on this device are uploaded once. - Alarms imported from an `.ics` file become reminders on the task. +- Screens now open and close the way they do in Calendula: they slide in, and + a back swipe previews the screen behind before closing (#66). +- Settings now use Calendula's names and layout. Reminders is now + Notifications, Task form is now New task form, and export and import moved + out of Storage into a new Backup & restore section (#66). + +### Fixed +- Fixed a crash when a task's date was shown in Russian and other languages + whose dates put a word after the year (#70). +- Widgets no longer turn into the Today widget or show "Can't load content" + after a task is ticked off (#92). +- Ticking off a task in the Tasks and Up next widgets now works in release + builds; until now the tap did nothing (#75). +- The Tasks widget leaves the same gap under its header as its preview (#86). +- The Starts and Due labels in the task form no longer get cut off at large + font sizes (#76). +- Adding an account by email address finds the server again for providers + that publish it in DNS, such as mailbox.org and Posteo. The DNS lookup never + reached the network, so they ended in "isn't a CalDAV server". It now goes + through Android's resolver and respects Private DNS (#97). +- When the server can't be found from your email address, account setup asks + for the server address straight away and keeps your email as the username, + instead of stopping at an error (#97). ## [1.1.0] - 2026-10-10 diff --git a/README.md b/README.md index e8c4da4..d135c61 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,10 @@ Agendula's own store is an ordinary app database, so it **coexists with OpenTasks rather than replacing it**. If you already sync through a provider, Agendula can work on top of it exactly as before. +Other apps can read and write the tasks in Agendula's own store through an +OpenTasks-compatible provider, once you grant them permission. See +[docs/CONTENT_PROVIDER.md](docs/CONTENT_PROVIDER.md). + Switching between the two moves nothing — each store keeps its own tasks — so **Settings → Storage** asks before it switches, and offers to **copy** a provider's tasks into Agendula's own store when you want to move over. The copy @@ -164,7 +168,7 @@ Agendula's own database go with it, so sync them to a CalDAV account first. Each Codeberg release also carries `agendula-offline_v.apk`: Agendula without CalDAV sync and **without the network permission**, so it cannot connect to anything. It installs alongside the regular app rather than replacing -it; move tasks between the two with Settings → Storage → Export / Import. In +it; move tasks between the two with Settings → Backup & restore → Export / Import. In Obtainium, pick it with *Filter APKs by regular expression* set to `^agendula-offline_v`. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 248701d..0b1c832 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 = 1010099 - versionName = "1.1.0" + versionCode = 1020099 + versionName = "1.2.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 007cf29..daf294f 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -7,8 +7,8 @@ # nothing calls it directly — Room then throws InstantiationException, reported # as "Failed to create an instance of ...". This first bit us through a # transitive Room (Glance -> WorkManager -> WorkDatabase, built at startup: -# issue #1); Glance is gone and Room is now our own task store, so the rule -# matters more, not less — TasksDatabase is built on the first store read. +# issue #1); Room is now also our own task store, so the rule matters more, +# not less — TasksDatabase is built on the first store read. -keep class * extends androidx.room.RoomDatabase { (); } # Compose Compiler may keep its own; defaults are fine diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/provider/AgendulaTaskProviderTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/provider/AgendulaTaskProviderTest.kt new file mode 100644 index 0000000..fd0f678 --- /dev/null +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/provider/AgendulaTaskProviderTest.kt @@ -0,0 +1,157 @@ +package de.jeanlucmakiola.agendula.data.tasks.provider + +import android.content.ContentProviderOperation +import android.content.ContentValues +import android.content.Context +import android.content.pm.PermissionInfo +import android.os.Build +import android.os.ParcelFileDescriptor +import androidx.core.net.toUri +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import org.junit.Assert.assertThrows +import org.junit.Assume.assumeTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** The provider as another app meets it: manifest, permission, URIs. */ +@RunWith(AndroidJUnit4::class) +class AgendulaTaskProviderTest { + + private val context = ApplicationProvider.getApplicationContext() + private val authority = "${context.packageName}.tasks" + private val readPermission = "${context.packageName}.permission.READ_TASKS" + private val writePermission = "${context.packageName}.permission.WRITE_TASKS" + private val resolver = context.contentResolver + + private fun uri(path: String) = "content://$authority/$path".toUri() + + @Test + fun isExportedUnderItsPermissions() { + val info = requireNotNull(context.packageManager.resolveContentProvider(authority, 0)) + + assertThat(info.name).isEqualTo(AgendulaTaskProvider::class.java.name) + assertThat(info.exported).isTrue() + assertThat(info.readPermission).isEqualTo(readPermission) + assertThat(info.writePermission).isEqualTo(writePermission) + } + + @Test + fun bothPermissionsAreDangerous() { + listOf(readPermission, writePermission).forEach { permission -> + val info = context.packageManager.getPermissionInfo(permission, 0) + + assertThat(info.protection).isEqualTo(PermissionInfo.PROTECTION_DANGEROUS) + assertThat(info.loadLabel(context.packageManager).toString()).isNotEmpty() + assertThat(info.loadDescription(context.packageManager)?.toString()).isNotEmpty() + } + } + + @Test + fun anAppWithoutThePermissionIsDenied() { + assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) + // The shell is another uid that was never granted it. + val output = shell("content query --uri content://$authority/tasks") + + assertThat(output).contains("Permission Denial") + } + + @Test + fun anAppWithoutTheWritePermissionCannotWrite() { + assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) + + val output = shell("content insert --uri content://$authority/tasks --bind title:s:x --bind list_id:i:1") + + assertThat(output).contains("Permission Denial") + assertThat(output).contains(writePermission) + } + + @Test + fun reportsOpenTasksMimeTypes() { + ProviderTable.entries.forEach { table -> + assertThat(resolver.getType(uri(table.path))).isEqualTo(table.mimeType(single = false)) + assertThat(resolver.getType(uri("${table.path}/3"))).isEqualTo(table.mimeType(single = true)) + } + } + + @Test + fun everyTableAnswersWithItsColumns() { + ProviderTable.entries.forEach { table -> + resolver.query(uri(table.path), null, null, null, null)!!.use { + assertThat(it.columnNames.toList()).containsExactlyElementsIn(table.columns.map { c -> c.first }).inOrder() + } + } + } + + @Test + fun aCursorWatchesTheUriItWasQueriedFor() { + val tasks = uri("tasks") + + resolver.query(tasks, null, null, null, null)!!.use { assertThat(it.notificationUri).isEqualTo(tasks) } + } + + @Test + fun aRowUriReadsOnlyThatRow() { + // Ids start at 1, so 0 names nothing whatever the store holds. + resolver.query(uri("tasks/0"), null, null, null, null)!!.use { assertThat(it.count).isEqualTo(0) } + resolver.query(uri("tasks/0"), null, "1 = 1 OR 1 = 1", null, null)!!.use { assertThat(it.count).isEqualTo(0) } + } + + @Test + fun anUnknownUriIsRejected() { + assertThrows(IllegalArgumentException::class.java) { resolver.query(uri("events"), null, null, null, null) } + // ContentResolver.getType swallows the provider's exception and answers null. + assertThat(runCatching { resolver.getType(uri("tasks/abc")) }.getOrNull()).isNull() + } + + @Test + fun listsAreReadOnly() { + val values = ContentValues().apply { put("list_name", "x") } + + assertThrows(UnsupportedOperationException::class.java) { resolver.insert(uri("tasklists"), values) } + assertThrows(UnsupportedOperationException::class.java) { resolver.update(uri("tasklists/1"), values, null, null) } + assertThrows(UnsupportedOperationException::class.java) { resolver.delete(uri("tasklists/1"), null, null) } + } + + @Test + fun aRowUriTakesNoInsert() { + assertThrows(IllegalArgumentException::class.java) { + resolver.insert(uri("tasks/1"), ContentValues().apply { put("title", "x") }) + } + } + + @Test + fun aBadWriteReachesTheClientAsIllegalArgument() { + // No list has this id, so nothing in the app's real store is touched. + val values = ContentValues().apply { + put("list_id", Long.MAX_VALUE) + put("title", "x") + } + + assertThrows(IllegalArgumentException::class.java) { resolver.insert(uri("tasks"), values) } + } + + @Test + fun aFailingBatchWritesNothing() { + val missingList = Long.MAX_VALUE + val operations = arrayListOf( + ContentProviderOperation.newInsert(uri("tasks")).withValue("list_id", missingList).withValue("title", "a").build(), + ) + + assertThrows(IllegalArgumentException::class.java) { resolver.applyBatch(authority, operations) } + resolver.query(uri("tasks"), arrayOf("_id"), "list_id = ?", arrayOf(missingList.toString()), null)!! + .use { assertThat(it.count).isEqualTo(0) } + } + + /** stdout and stderr of [command]; the denial lands on stderr. */ + private fun shell(command: String): String { + val (stdout, stdin, stderr) = InstrumentationRegistry.getInstrumentation().uiAutomation + .executeShellCommandRwe(command) + stdin.close() + return listOf(stdout, stderr).joinToString("\n") { pfd -> + ParcelFileDescriptor.AutoCloseInputStream(pfd).bufferedReader().use { it.readText() } + } + } +} diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderSnapshotSourceTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderSnapshotSourceTest.kt new file mode 100644 index 0000000..0d50754 --- /dev/null +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderSnapshotSourceTest.kt @@ -0,0 +1,214 @@ +package de.jeanlucmakiola.agendula.data.tasks.provider + +import android.content.Context +import android.database.ContentObserver +import android.database.Cursor +import android.net.Uri +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.agendula.data.tasks.ProviderEnvironment +import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver +import de.jeanlucmakiola.agendula.data.tasks.StorageMode +import de.jeanlucmakiola.agendula.data.tasks.TasksContract +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks +import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity +import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity +import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity +import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit +import kotlin.time.Clock +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Instant + +/** The snapshot follows the Room store, the storage mode and the clock, and says when it moved. */ +@RunWith(AndroidJUnit4::class) +class ProviderSnapshotSourceTest { + + private val context = ApplicationProvider.getApplicationContext() + private lateinit var db: TasksDatabase + private lateinit var resolver: ProviderResolver + private lateinit var source: ProviderSnapshotSource + private val clock = MutableClock(Instant.parse("2026-10-10T08:00:00Z")) + private val changes = LinkedBlockingQueue() + private val observer = object : ContentObserver(null) { + override fun onChange(selfChange: Boolean, uri: Uri?) { + changes += uri ?: Uri.EMPTY + } + } + private var listId = 0L + + @Before + fun setUp() { + db = Room.inMemoryDatabaseBuilder(context, TasksDatabase::class.java).allowMainThreadQueries().build() + resolver = ProviderResolver(NoProviderInstalled) + source = ProviderSnapshotSource(context, db, resolver, awaitStartup = {}, clock = clock) + listId = db.taskLists().insert(TaskListEntity(name = "Home", color = 0xFF112233.toInt())) + context.contentResolver.registerContentObserver(TasksContract.authorityUri(source.authority), true, observer) + } + + @After + fun tearDown() { + context.contentResolver.unregisterContentObserver(observer) + db.close() + } + + private fun query(table: ProviderTable, selection: String? = null, args: Array? = null): Cursor = + source.query(table, null, selection, args, null) + + private fun titles(): List = query(ProviderTable.TASKS).use { cursor -> + val index = cursor.getColumnIndexOrThrow(Tasks.TITLE) + buildList { while (cursor.moveToNext()) add(cursor.getString(index)) } + } + + private fun insert(title: String, due: Instant? = null, rrule: String? = null): Long = + db.tasks().insert(TaskEntity(listId = listId, uid = title, title = title, due = due, rrule = rrule, timezone = "UTC")) + + /** Waits for the coalesced notification, then drains any stragglers. */ + private fun awaitChange() { + assertThat(changes.poll(5, TimeUnit.SECONDS)).isNotNull() + Thread.sleep(700) + changes.clear() + } + + private fun assertNoChange() { + assertThat(changes.poll(1500, TimeUnit.MILLISECONDS)).isNull() + } + + @Test + fun servesTheOwnStore() { + val id = insert("Milk") + db.alarms().insert(TaskAlarmEntity(taskId = id, minutesBefore = 10)) + + assertThat(titles()).containsExactly("Milk") + query(ProviderTable.LISTS).use { + it.moveToFirst() + assertThat(it.getString(it.getColumnIndexOrThrow(Lists.NAME))).isEqualTo("Home") + } + query(ProviderTable.INSTANCES).use { assertThat(it.count).isEqualTo(1) } + query(ProviderTable.PROPERTIES).use { assertThat(it.count).isEqualTo(1) } + } + + @Test + fun aDeletedTaskIsNotPublished() { + val id = insert("Gone") + insert("Kept") + db.tasks().markDeleted(id, clock.now()) + + assertThat(titles()).containsExactly("Kept") + } + + @Test + fun aWriteNotifiesObserversAndIsServedNext() { + source.start() + assertThat(titles()).isEmpty() + + insert("Later") + + awaitChange() + assertThat(titles()).containsExactly("Later") + } + + @Test + fun aBurstOfWritesNotifiesOnce() { + source.start() + titles() + + repeat(5) { insert("t$it") } + + assertThat(changes.poll(5, TimeUnit.SECONDS)).isNotNull() + assertNoChange() + } + + @Test + fun externalModeServesNothing() { + insert("Mine") + resolver.storageMode = StorageMode.EXTERNAL + + ProviderTable.entries.forEach { table -> query(table).use { assertThat(it.count).isEqualTo(0) } } + } + + @Test + fun switchingModeNotifiesAndRebuilds() { + source.start() + insert("Mine") + assertThat(titles()).containsExactly("Mine") + changes.clear() + + resolver.storageMode = StorageMode.EXTERNAL + awaitChange() + assertThat(titles()).isEmpty() + + resolver.storageMode = StorageMode.OWN + awaitChange() + assertThat(titles()).containsExactly("Mine") + } + + @Test + fun writesInExternalModeDoNotNotify() { + resolver.storageMode = StorageMode.EXTERNAL + source.start() + titles() + + insert("Hidden") + + assertNoChange() + } + + @Test + fun aClientSelectionNarrowsTheRows() { + insert("a") + insert("b") + + query(ProviderTable.TASKS, "${Tasks.TITLE} = ?", arrayOf("b")).use { assertThat(it.count).isEqualTo(1) } + } + + @Test + fun anHourOldSnapshotIsRebuiltAgainstTheClock() { + source.start() + insert("Daily", due = Instant.parse("2026-10-10T09:00:00Z"), rrule = "FREQ=DAILY;COUNT=3") + awaitChange() + assertThat(currentOriginalTime()).isEqualTo(Instant.parse("2026-10-10T09:00:00Z").toEpochMilliseconds()) + + clock.current += 1.days + + assertThat(currentOriginalTime()).isEqualTo(Instant.parse("2026-10-11T09:00:00Z").toEpochMilliseconds()) + awaitChange() + } + + @Test + fun aFreshSnapshotIsReused() { + insert("a") + titles() + + clock.current += 30.minutes + + query(ProviderTable.INSTANCES).use { assertThat(it.count).isEqualTo(1) } + assertNoChange() + } + + private fun currentOriginalTime(): Long = + query(ProviderTable.INSTANCES, "${Instances.DISTANCE_FROM_CURRENT} = 0").use { + assertThat(it.moveToFirst()).isTrue() + it.getLong(it.getColumnIndexOrThrow(Instances.INSTANCE_ORIGINAL_TIME)) + } +} + +private class MutableClock(var current: Instant) : Clock { + override fun now(): Instant = current +} + +private object NoProviderInstalled : ProviderEnvironment { + override fun packageDeclaring(authority: String): String? = null + override fun isGranted(permission: String): Boolean = false + override fun appLabel(packageName: String): String? = null +} diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderWriterTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderWriterTest.kt new file mode 100644 index 0000000..f4383a9 --- /dev/null +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderWriterTest.kt @@ -0,0 +1,811 @@ +package de.jeanlucmakiola.agendula.data.tasks.provider + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.agendula.data.tasks.ProviderEnvironment +import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver +import de.jeanlucmakiola.agendula.data.tasks.StorageMode +import de.jeanlucmakiola.agendula.data.tasks.TasksContract +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Alarm +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Category +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks +import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity +import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference +import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource +import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity +import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity +import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity +import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase +import de.jeanlucmakiola.agendula.domain.TaskStatus +import org.junit.After +import org.junit.Assert.assertThrows +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.time.Clock +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.awaitCancellation +import kotlin.time.Instant + +/** Writes from another app, applied to the own store through the published tables. */ +@RunWith(AndroidJUnit4::class) +class ProviderWriterTest { + + private val context = ApplicationProvider.getApplicationContext() + private lateinit var db: TasksDatabase + private lateinit var resolver: ProviderResolver + private lateinit var source: ProviderSnapshotSource + private lateinit var writer: ProviderWriter + private var afterWrites = 0 + private var home = 0L + + /** Truncated to the store's granularity, and on the hour so occurrences land on round times. */ + private val today = Instant.fromEpochMilliseconds(Clock.System.now().toEpochMilliseconds() / 3_600_000 * 3_600_000) + + @Before + fun setUp() { + db = Room.inMemoryDatabaseBuilder(context, TasksDatabase::class.java).allowMainThreadQueries().build() + resolver = ProviderResolver(NoProvider) + source = ProviderSnapshotSource(context, db, resolver, awaitStartup = {}) + writer = ProviderWriter(db, RoomTasksDataSource(db), source, resolver, afterWrite = { afterWrites++ }) + home = db.taskLists().insert(TaskListEntity(name = "Home", color = 0)) + } + + @After + fun tearDown() = db.close() + + private fun task(id: Long) = db.tasks().entity(id) + + private fun insert(vararg values: Pair): Long = + writer.insert(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to home, *values)) + + private fun byId(table: ProviderTable, id: Long) = "${table.idColumn} = $id" + + private fun synced(readOnly: Boolean = false): Long { + val account = db.accounts().insert(AccountEntity(displayName = "me", username = "me")) + return db.taskLists().insert( + TaskListEntity(name = "Work", color = 0, accountId = account, href = "https://s/w/", isReadOnly = readOnly), + ) + } + + private fun dailySeries(title: String = "Daily"): Long = db.tasks().insert( + TaskEntity(listId = home, uid = title, title = title, dtstart = today - 1.days, due = today - 1.days + 1.hours, + rrule = "FREQ=DAILY;COUNT=5", timezone = "UTC"), + ) + + /** Each instance of [taskId]'s series by its original time, with its task row and status. */ + private fun instances(): List> = + source.rows( + ProviderTable.INSTANCES, + listOf(Instances.INSTANCE_ORIGINAL_TIME, Instances.TASK_ID, Tasks.STATUS), + null, + null, + ).map { (original, taskId, status) -> Triple(original as Long, taskId as Long, (status as Long).toInt()) } + + private fun instanceId(original: Instant): Long = + source.rows( + ProviderTable.INSTANCES, + listOf(Tasks.ID), + "${Instances.INSTANCE_ORIGINAL_TIME} = ?", + arrayOf(original.toEpochMilliseconds().toString()), + ).single().single() as Long + + // --- inserts ---------------------------------------------------------------- + + @Test + fun anInsertedTaskIsPublishedAndOwedASync() { + val id = insert(Tasks.TITLE to "Milk", Tasks.PRIORITY to 1) + + val stored = task(id)!! + assertThat(stored.title).isEqualTo("Milk") + assertThat(stored.priority).isEqualTo(1) + assertThat(stored.isDirty).isTrue() + assertThat(source.rows(ProviderTable.TASKS, listOf(Tasks.TITLE), byId(ProviderTable.TASKS, id), null)) + .containsExactly(listOf("Milk")) + assertThat(afterWrites).isEqualTo(1) + } + + @Test + fun anInsertNeedsAListThatExistsAndIsWritable() { + assertThrows(IllegalArgumentException::class.java) { writer.insert(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x")) } + assertThrows(IllegalArgumentException::class.java) { insert(Tasks.LIST_ID to 999L) } + val readOnly = synced(readOnly = true) + assertThrows(UnsupportedOperationException::class.java) { insert(Tasks.LIST_ID to readOnly) } + assertThat(db.tasks().live()).isEmpty() + } + + @Test + fun anOccurrenceCannotBeInsertedAsATask() { + val series = dailySeries() + + assertThrows(IllegalArgumentException::class.java) { + insert(Tasks.ORIGINAL_INSTANCE_ID to series, Tasks.ORIGINAL_INSTANCE_TIME to today.toEpochMilliseconds()) + } + } + + @Test + fun aDuplicateUidIsRejected() { + insert(Tasks.UID to "same") + + assertThrows(IllegalArgumentException::class.java) { insert(Tasks.UID to "same") } + } + + @Test + fun listsAndInstancesTakeNoInserts() { + assertThrows(UnsupportedOperationException::class.java) { + writer.insert(ProviderTable.LISTS, mapOf(TasksContract.Lists.NAME to "New")) + } + assertThrows(UnsupportedOperationException::class.java) { writer.insert(ProviderTable.INSTANCES, emptyMap()) } + assertThrows(UnsupportedOperationException::class.java) { + writer.update(ProviderTable.LISTS, mapOf(TasksContract.Lists.NAME to "x"), null, null) + } + assertThrows(UnsupportedOperationException::class.java) { writer.delete(ProviderTable.LISTS, null, null) } + } + + @Test + fun anUnknownColumnIsRejected() { + assertThrows(IllegalArgumentException::class.java) { insert("etag" to "x") } + } + + @Test + fun nothingIsWrittenInExternalMode() { + val id = insert(Tasks.TITLE to "Mine") + resolver.storageMode = StorageMode.EXTERNAL + + assertThrows(IllegalStateException::class.java) { insert() } + assertThrows(IllegalStateException::class.java) { + writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), null, null) + } + assertThat(task(id)!!.title).isEqualTo("Mine") + } + + // --- updates ---------------------------------------------------------------- + + @Test + fun anUpdateChangesTheRowsItsSelectionMatches() { + val a = insert(Tasks.TITLE to "a") + val b = insert(Tasks.TITLE to "b") + + val count = writer.update(ProviderTable.TASKS, mapOf(Tasks.STATUS to 2), "${Tasks.TITLE} = ?", arrayOf("b")) + + assertThat(count).isEqualTo(1) + assertThat(task(a)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION) + assertThat(task(b)!!.status).isEqualTo(TaskStatus.COMPLETED) + assertThat(task(b)!!.completedAt).isNotNull() + } + + @Test + fun anUpdateMatchingNothingChangesNothing() { + insert(Tasks.TITLE to "a") + + assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), "${Tasks.TITLE} = 'zzz'", null)).isEqualTo(0) + } + + @Test + fun aTaskInAReadOnlyListCannotChange() { + val readOnly = synced(readOnly = true) + val id = db.tasks().insert(TaskEntity(listId = readOnly, uid = "r", title = "theirs")) + + assertThrows(UnsupportedOperationException::class.java) { + writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "mine"), byId(ProviderTable.TASKS, id), null) + } + assertThrows(UnsupportedOperationException::class.java) { + writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, id), null) + } + assertThat(task(id)!!.title).isEqualTo("theirs") + } + + @Test + fun aTaskMovesBetweenLists() { + val work = synced() + val id = insert(Tasks.TITLE to "t") + + writer.update(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to work), byId(ProviderTable.TASKS, id), null) + + assertThat(db.tasks().live().single { it.title == "t" }.listId).isEqualTo(work) + } + + @Test + fun aParentMustBeInTheSameListAndNotADescendant() { + val work = synced() + val parent = insert(Tasks.TITLE to "parent") + val child = insert(Tasks.TITLE to "child", Tasks.PARENT_ID to parent) + val elsewhere = insert(Tasks.LIST_ID to work, Tasks.TITLE to "elsewhere") + + assertThat(task(child)!!.parentId).isEqualTo(parent) + assertThrows(IllegalArgumentException::class.java) { + writer.update(ProviderTable.TASKS, mapOf(Tasks.PARENT_ID to elsewhere), byId(ProviderTable.TASKS, child), null) + } + assertThrows(IllegalArgumentException::class.java) { + writer.update(ProviderTable.TASKS, mapOf(Tasks.PARENT_ID to child), byId(ProviderTable.TASKS, parent), null) + } + assertThrows(IllegalArgumentException::class.java) { insert(Tasks.PARENT_ID to 999L) } + } + + @Test + fun movingASeriesDropsTheOverridesItNoLongerGenerates() { + val series = dailySeries() + writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null) + assertThat(db.tasks().overrides(series)).hasSize(1) + + writer.update( + ProviderTable.TASKS, + mapOf(Tasks.DTSTART to (today + 2.hours).toEpochMilliseconds(), Tasks.DUE to (today + 3.hours).toEpochMilliseconds()), + byId(ProviderTable.TASKS, series), + null, + ) + + assertThat(db.tasks().overrides(series)).isEmpty() + } + + @Test + fun aClientCannotTouchSyncState() { + val work = synced() + val id = db.tasks().insert( + TaskEntity(listId = work, uid = "s", title = "t", href = "/s.ics", etag = "e", sequence = 2, unknownProperties = "X-A:1"), + ) + + writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "new", "_sync_id" to "x", "sync1" to "y"), byId(ProviderTable.TASKS, id), null) + + val stored = task(id)!! + assertThat(stored.title).isEqualTo("new") + assertThat(stored.href).isEqualTo("/s.ics") + assertThat(stored.etag).isEqualTo("e") + assertThat(stored.sequence).isEqualTo(2) + assertThat(stored.unknownProperties).isEqualTo("X-A:1") + assertThat(stored.isDirty).isTrue() + } + + // --- instances -------------------------------------------------------------- + + @Test + fun completingAnOccurrenceTicksOffOnlyThatOne() { + val series = dailySeries() + + val count = writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null) + + assertThat(count).isEqualTo(1) + assertThat(task(series)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION) + val override = db.tasks().overrides(series).single() + assertThat(override.recurrenceId).isEqualTo(today) + assertThat(override.status).isEqualTo(TaskStatus.COMPLETED) + val statuses = instances().associate { it.first to it.third } + assertThat(statuses[today.toEpochMilliseconds()]).isEqualTo(TasksContract.STATUS_COMPLETED) + assertThat(statuses.filterKeys { it != today.toEpochMilliseconds() }.values.toSet()) + .containsExactly(TasksContract.STATUS_NEEDS_ACTION) + } + + @Test + fun anOccurrenceCanBeCancelledAndReopened() { + val series = dailySeries() + + writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 3), byId(ProviderTable.INSTANCES, instanceId(today)), null) + assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.CANCELLED) + + writer.update(ProviderTable.INSTANCES, mapOf(Tasks.PERCENT_COMPLETE to 0), byId(ProviderTable.INSTANCES, instanceId(today)), null) + assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.NEEDS_ACTION) + } + + @Test + fun contentWrittenToAnOccurrenceEditsTheSeries() { + val series = dailySeries() + + writer.update(ProviderTable.INSTANCES, mapOf(Tasks.TITLE to "Renamed"), byId(ProviderTable.INSTANCES, instanceId(today)), null) + + assertThat(task(series)!!.title).isEqualTo("Renamed") + assertThat(db.tasks().overrides(series)).isEmpty() + } + + @Test + fun anOccurrenceCannotBeMoved() { + dailySeries() + + assertThrows(IllegalArgumentException::class.java) { + writer.update(ProviderTable.INSTANCES, mapOf(Tasks.DUE to 0L), byId(ProviderTable.INSTANCES, instanceId(today)), null) + } + } + + @Test + fun anOccurrenceHasNoPartialProgress() { + dailySeries() + + assertThrows(IllegalArgumentException::class.java) { + writer.update(ProviderTable.INSTANCES, mapOf(Tasks.PERCENT_COMPLETE to 50), byId(ProviderTable.INSTANCES, instanceId(today)), null) + } + } + + @Test + fun aPlainTaskInstanceWritesTheTask() { + val id = insert(Tasks.TITLE to "t", Tasks.DUE to today.toEpochMilliseconds()) + val instance = source.rows(ProviderTable.INSTANCES, listOf(Tasks.ID), "${Instances.TASK_ID} = $id", null).single().single() as Long + + writer.update(ProviderTable.INSTANCES, mapOf(Tasks.PERCENT_COMPLETE to 50, Tasks.DUE to (today + 1.days).toEpochMilliseconds()), byId(ProviderTable.INSTANCES, instance), null) + + assertThat(task(id)!!.status).isEqualTo(TaskStatus.IN_PROCESS) + assertThat(task(id)!!.due).isEqualTo(today + 1.days) + } + + @Test + fun deletingAnOccurrenceExceptsIt() { + val series = dailySeries() + + assertThat(writer.delete(ProviderTable.INSTANCES, byId(ProviderTable.INSTANCES, instanceId(today)), null)).isEqualTo(1) + + assertThat(task(series)!!.exdate).isNotNull() + assertThat(instances().map { it.first }).doesNotContain(today.toEpochMilliseconds()) + assertThat(instances()).hasSize(4) + } + + @Test + fun deletingAModifiedOccurrenceExceptsItToo() { + val series = dailySeries() + writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null) + + writer.delete(ProviderTable.INSTANCES, byId(ProviderTable.INSTANCES, instanceId(today)), null) + + assertThat(db.tasks().overrides(series)).isEmpty() + assertThat(instances().map { it.first }).doesNotContain(today.toEpochMilliseconds()) + } + + // --- deletes ---------------------------------------------------------------- + + @Test + fun aParentGoesWithItsSubtasks() { + val parent = insert(Tasks.TITLE to "parent") + val child = insert(Tasks.TITLE to "child", Tasks.PARENT_ID to parent) + val grandchild = insert(Tasks.TITLE to "grandchild", Tasks.PARENT_ID to child) + val other = insert(Tasks.TITLE to "other") + + assertThat(writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, parent), null)).isEqualTo(1) + + assertThat(task(parent)).isNull() + assertThat(task(child)).isNull() + assertThat(task(grandchild)).isNull() + assertThat(task(other)).isNotNull() + } + + @Test + fun aSyncedTaskLeavesATombstone() { + val work = synced() + val id = insert(Tasks.LIST_ID to work, Tasks.TITLE to "t") + + writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, id), null) + + assertThat(task(id)!!.isDeleted).isTrue() + assertThat(source.rows(ProviderTable.TASKS, listOf(Tasks.ID), null, null)).isEmpty() + assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), byId(ProviderTable.TASKS, id), null)).isEqualTo(0) + } + + @Test + fun deletingAnOverrideThroughTasksExceptsTheOccurrence() { + val series = dailySeries() + writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null) + val override = db.tasks().overrides(series).single().id + + writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, override), null) + + assertThat(task(series)).isNotNull() + assertThat(task(series)!!.exdate).isNotNull() + assertThat(instances()).hasSize(4) + } + + // --- properties ------------------------------------------------------------- + + @Test + fun anAlarmCanBeAddedChangedAndRemoved() { + val id = insert(Tasks.TITLE to "t", Tasks.DUE to (today + 1.days).toEpochMilliseconds()) + db.tasks().update(task(id)!!.copy(isDirty = false)) + + val propertyId = writer.insert( + ProviderTable.PROPERTIES, + mapOf(Properties.TASK_ID to id, Properties.MIMETYPE to Alarm.MIMETYPE, Alarm.MINUTES_BEFORE to "15", Alarm.REFERENCE to "1"), + ) + val alarm = db.alarms().forTask(id).single() + assertThat(alarm.minutesBefore).isEqualTo(15) + assertThat(alarm.reference).isEqualTo(AlarmReference.DUE) + assertThat(task(id)!!.isDirty).isTrue() + assertThat(propertyId).isEqualTo(ProviderIds.alarm(alarm.id)) + + writer.update(ProviderTable.PROPERTIES, mapOf(Alarm.MINUTES_BEFORE to 60, Alarm.REFERENCE to 2), byId(ProviderTable.PROPERTIES, propertyId), null) + assertThat(db.alarms().forTask(id).single().minutesBefore).isEqualTo(60) + assertThat(db.alarms().forTask(id).single().reference).isEqualTo(AlarmReference.START) + + writer.delete(ProviderTable.PROPERTIES, "${Properties.MIMETYPE} = ?", arrayOf(Alarm.MIMETYPE)) + assertThat(db.alarms().forTask(id)).isEmpty() + } + + @Test + fun tagsCanBeAddedRenamedAndRemoved() { + val id = insert(Tasks.TITLE to "t") + + val propertyId = writer.insert( + ProviderTable.PROPERTIES, + mapOf(Properties.TASK_ID to id, Properties.MIMETYPE to Category.MIMETYPE, Category.NAME to " errand "), + ) + assertThat(task(id)!!.categories).containsExactly("errand") + + writer.update(ProviderTable.PROPERTIES, mapOf(Category.NAME to "shopping"), byId(ProviderTable.PROPERTIES, propertyId), null) + assertThat(task(id)!!.categories).containsExactly("shopping") + + writer.insert(ProviderTable.PROPERTIES, mapOf(Properties.TASK_ID to id, Properties.MIMETYPE to Category.MIMETYPE, Category.NAME to "shopping")) + assertThat(task(id)!!.categories).containsExactly("shopping") + + writer.delete(ProviderTable.PROPERTIES, "${Category.NAME} = ?", arrayOf("shopping")) + assertThat(task(id)!!.categories).isEmpty() + } + + @Test + fun aPropertyCannotChangeItsKindOrTask() { + val id = insert(Tasks.TITLE to "t") + val other = insert(Tasks.TITLE to "o") + db.alarms().insert(TaskAlarmEntity(taskId = id, minutesBefore = 5)) + val propertyId = source.rows(ProviderTable.PROPERTIES, listOf(Properties.PROPERTY_ID), null, null).single().single() as Long + + assertThrows(IllegalArgumentException::class.java) { + writer.update(ProviderTable.PROPERTIES, mapOf(Properties.MIMETYPE to Category.MIMETYPE), byId(ProviderTable.PROPERTIES, propertyId), null) + } + assertThrows(IllegalArgumentException::class.java) { + writer.update(ProviderTable.PROPERTIES, mapOf(Properties.TASK_ID to other), byId(ProviderTable.PROPERTIES, propertyId), null) + } + } + + @Test + fun onlyAlarmsAndTagsCanBeWritten() { + val id = insert(Tasks.TITLE to "t") + + assertThrows(IllegalArgumentException::class.java) { + writer.insert(ProviderTable.PROPERTIES, mapOf(Properties.TASK_ID to id, Properties.MIMETYPE to "vnd.android.cursor.item/comment")) + } + assertThrows(IllegalArgumentException::class.java) { + writer.insert(ProviderTable.PROPERTIES, mapOf(Properties.TASK_ID to id, Properties.MIMETYPE to Alarm.MIMETYPE)) + } + } + + // --- batches ---------------------------------------------------------------- + + @Test + fun aBatchIsAllOrNothing() { + assertThrows(IllegalArgumentException::class.java) { + writer.batch { + insert(Tasks.TITLE to "first") + insert(Tasks.TITLE to "second") + insert(Tasks.LIST_ID to 999L) + } + } + + assertThat(db.tasks().live()).isEmpty() + assertThat(source.rows(ProviderTable.TASKS, listOf(Tasks.ID), null, null)).isEmpty() + } + + @Test + fun aBatchSeesItsOwnWritesAndReArmsRemindersOnce() { + writer.batch { + insert(Tasks.TITLE to "first") + writer.update(ProviderTable.TASKS, mapOf(Tasks.STATUS to 2), "${Tasks.TITLE} = ?", arrayOf("first")) + } + + assertThat(db.tasks().live().single().status).isEqualTo(TaskStatus.COMPLETED) + assertThat(afterWrites).isEqualTo(1) + } + + // --- review regressions ----------------------------------------------------- + + @Test + fun aBatchDeletingTwoPropertiesDeletesThoseTwo() { + val a = insert(Tasks.TITLE to "a") + val b = insert(Tasks.TITLE to "b") + listOf(5, 10).forEach { db.alarms().insert(TaskAlarmEntity(taskId = a, minutesBefore = it)) } + db.alarms().insert(TaskAlarmEntity(taskId = b, minutesBefore = 15)) + val ids = source.rows(ProviderTable.PROPERTIES, listOf(Properties.PROPERTY_ID), "${Properties.TASK_ID} = $a", null) + .map { it.single() as Long } + + writer.batch { ids.forEach { writer.delete(ProviderTable.PROPERTIES, byId(ProviderTable.PROPERTIES, it), null) } } + + assertThat(db.alarms().forTask(a)).isEmpty() + assertThat(db.alarms().forTask(b).map { it.minutesBefore }).containsExactly(15) + } + + @Test + fun aBatchDeletingTwoOccurrencesDeletesThoseTwo() { + dailySeries() + val first = instanceId(today) + val second = instanceId(today + 1.days) + + writer.batch { + writer.delete(ProviderTable.INSTANCES, byId(ProviderTable.INSTANCES, first), null) + writer.delete(ProviderTable.INSTANCES, byId(ProviderTable.INSTANCES, second), null) + } + + assertThat(instances().map { it.first }).containsExactly( + (today - 1.days).toEpochMilliseconds(), + (today + 2.days).toEpochMilliseconds(), + (today + 3.days).toEpochMilliseconds(), + ) + } + + @Test + fun anInstanceIdOutlivesAnotherOccurrencesDeletion() { + dailySeries() + val later = instanceId(today + 2.days) + + writer.delete(ProviderTable.INSTANCES, byId(ProviderTable.INSTANCES, instanceId(today)), null) + + assertThat(instanceId(today + 2.days)).isEqualTo(later) + assertThat(writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, later), null)) + .isEqualTo(1) + assertThat(instances().single { it.third == TasksContract.STATUS_COMPLETED }.first) + .isEqualTo((today + 2.days).toEpochMilliseconds()) + } + + @Test + fun aRecurringSubtaskOutsideTheWindowGoesWithItsParent() { + val parent = insert(Tasks.TITLE to "parent") + val longAgo = today - 1000.days + val subtask = db.tasks().insert( + TaskEntity(listId = home, uid = "old", title = "old", dtstart = longAgo, rrule = "FREQ=DAILY;COUNT=2", + timezone = "UTC", parentId = parent), + ) + + writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, parent), null) + + assertThat(task(subtask)).isNull() + } + + @Test + fun anOverriddenRecurringSubtaskGoesWholeWithItsParent() { + val parent = insert(Tasks.TITLE to "parent") + val subtask = dailySeries("sub") + db.tasks().update(task(subtask)!!.copy(parentId = parent)) + writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null) + + writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, parent), null) + + assertThat(task(subtask)).isNull() + assertThat(db.tasks().live()).isEmpty() + } + + @Test + fun aModifiedOccurrenceCanBeWrittenBackAsRead() { + val series = dailySeries() + writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null) + val override = db.tasks().overrides(series).single().id + val row = readRow(ProviderTable.TASKS, override) + + writer.update(ProviderTable.TASKS, row + (Tasks.TITLE to "Renamed"), byId(ProviderTable.TASKS, override), null) + + assertThat(task(override)!!.title).isEqualTo("Renamed") + } + + @Test + fun anInstanceCanBeWrittenBackAsReadToCompleteIt() { + val series = dailySeries() + val instance = instanceId(today) + val row = readRow(ProviderTable.INSTANCES, instance) + + writer.update(ProviderTable.INSTANCES, row + (Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instance), null) + + assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.COMPLETED) + assertThat(task(series)!!.dtstart).isEqualTo(today - 1.days) + } + + @Test + fun movingASubtreeMovesItsSubtasks() { + val work = synced() + val parent = insert(Tasks.TITLE to "parent") + val child = insert(Tasks.TITLE to "child", Tasks.PARENT_ID to parent) + val grandchild = insert(Tasks.TITLE to "grandchild", Tasks.PARENT_ID to child) + + writer.update(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to work), byId(ProviderTable.TASKS, parent), null) + + assertThat(listOf(parent, child, grandchild).map { task(it)!!.listId }).containsExactly(work, work, work) + assertThat(task(child)!!.parentId).isEqualTo(parent) + assertThat(task(grandchild)!!.parentId).isEqualTo(child) + } + + @Test + fun aMoveOntoATakenUidIsRejected() { + val work = synced() + insert(Tasks.LIST_ID to work, Tasks.UID to "same") + val id = insert(Tasks.UID to "same") + + assertThrows(IllegalArgumentException::class.java) { + writer.update(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to work), byId(ProviderTable.TASKS, id), null) + } + assertThat(task(id)!!.listId).isEqualTo(home) + } + + @Test + fun contentOnAModifiedOccurrenceStillEditsTheSeries() { + val series = dailySeries() + writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null) + val override = db.tasks().overrides(series).single().id + + writer.update(ProviderTable.INSTANCES, mapOf(Tasks.TITLE to "Renamed"), byId(ProviderTable.INSTANCES, instanceId(today)), null) + + assertThat(task(series)!!.title).isEqualTo("Renamed") + assertThat(task(override)!!.title).isEqualTo("Renamed") + assertThat(task(override)!!.status).isEqualTo(TaskStatus.COMPLETED) + } + + @Test + fun aWriteThatChangesNothingOwesNoSync() { + val id = insert(Tasks.TITLE to "t") + db.tasks().update(task(id)!!.copy(isDirty = false)) + val modified = task(id)!!.lastModified + + writer.update(ProviderTable.TASKS, readRow(ProviderTable.TASKS, id), byId(ProviderTable.TASKS, id), null) + writer.update(ProviderTable.TASKS, emptyMap(), byId(ProviderTable.TASKS, id), null) + + assertThat(task(id)!!.isDirty).isFalse() + assertThat(task(id)!!.lastModified).isEqualTo(modified) + } + + @Test + fun reopeningAnOpenOccurrenceForksNothing() { + val series = dailySeries() + + writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 0), byId(ProviderTable.INSTANCES, instanceId(today)), null) + + assertThat(db.tasks().overrides(series)).isEmpty() + } + + @Test + fun aWriteBeforeStartupSettlesIsRefused() { + val stuck = ProviderSnapshotSource(context, db, resolver, awaitStartup = { awaitCancellation() }, startupWait = 50.milliseconds) + val early = ProviderWriter(db, RoomTasksDataSource(db), stuck, resolver, afterWrite = {}) + + assertThrows(IllegalStateException::class.java) { early.insert(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to home)) } + assertThat(db.tasks().live()).isEmpty() + } + + @Test + fun aSingleRowWriteNeedsNoSnapshot() { + val id = insert(Tasks.TITLE to "t") + val unknown = 987_654L + + assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), byId(ProviderTable.TASKS, unknown), null)).isEqualTo(0) + assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), byId(ProviderTable.TASKS, id), null)).isEqualTo(1) + assertThat(writer.update(ProviderTable.INSTANCES, mapOf(Tasks.TITLE to "x"), byId(ProviderTable.INSTANCES, unknown), null)).isEqualTo(0) + assertThat(writer.delete(ProviderTable.PROPERTIES, byId(ProviderTable.PROPERTIES, unknown), null)).isEqualTo(0) + } + + // --- second review regressions ----------------------------------------------- + + @Test + fun tickingOffAModifiedOccurrenceAsReadLeavesTheSeriesAlone() { + val series = dailySeries("Standup") + // Reopening forks an override, which then gets its own title as the app's "this occurrence" edit would. + store().setCompletedInstance(series, today, false) + val override = db.tasks().overrides(series).single() + db.tasks().update(override.copy(title = "Standup (moved room)")) + val row = readRow(ProviderTable.INSTANCES, instanceId(today)) + + writer.update(ProviderTable.INSTANCES, row + (Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null) + + assertThat(task(series)!!.title).isEqualTo("Standup") + assertThat(task(override.id)!!.title).isEqualTo("Standup (moved room)") + assertThat(task(override.id)!!.status).isEqualTo(TaskStatus.COMPLETED) + } + + @Test + fun anInstanceWrittenBackWithOnlyProgressChangedIsCompleted() { + val series = dailySeries() + val row = readRow(ProviderTable.INSTANCES, instanceId(today)) + + writer.update(ProviderTable.INSTANCES, row + (Tasks.PERCENT_COMPLETE to 100), byId(ProviderTable.INSTANCES, instanceId(today)), null) + + assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.COMPLETED) + } + + @Test + fun aBadZoneOnAnInstanceReportsTheZone() { + dailySeries() + val instance = instanceId(today) + + val error = assertThrows(IllegalArgumentException::class.java) { + writer.update(ProviderTable.INSTANCES, mapOf(Tasks.TZ to "Europe/Berln"), byId(ProviderTable.INSTANCES, instance), null) + } + + assertThat(error).hasMessageThat().contains("Unknown time zone") + } + + @Test + fun anInProcessSeriesCanBeWrittenBackAsRead() { + val series = dailySeries() + db.tasks().update(task(series)!!.copy(status = TaskStatus.IN_PROCESS, percentComplete = 50)) + val row = readRow(ProviderTable.INSTANCES, instanceId(today)) + + writer.update(ProviderTable.INSTANCES, row + (Tasks.TITLE to "Renamed"), byId(ProviderTable.INSTANCES, instanceId(today)), null) + + assertThat(task(series)!!.title).isEqualTo("Renamed") + assertThat(task(series)!!.status).isEqualTo(TaskStatus.IN_PROCESS) + assertThat(db.tasks().overrides(series)).isEmpty() + } + + @Test + fun aFloatingSeriesCanBeWrittenBackAsRead() { + val series = db.tasks().insert( + TaskEntity(listId = home, uid = "f", title = "Floating", due = today, rrule = "FREQ=DAILY;COUNT=3", timezone = null), + ) + val row = readRow(ProviderTable.INSTANCES, instanceId(today)) + + writer.update(ProviderTable.INSTANCES, row + (Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null) + writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "Still floating"), byId(ProviderTable.TASKS, series), null) + + assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.COMPLETED) + assertThat(task(series)!!.timezone).isNull() + } + + @Test + fun aParentLoopDeletesInsteadOfRecursingForever() { + val a = insert(Tasks.TITLE to "a") + val b = insert(Tasks.TITLE to "b", Tasks.PARENT_ID to a) + db.tasks().update(task(a)!!.copy(parentId = b)) + + writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, a), null) + + assertThat(task(a)).isNull() + assertThat(task(b)).isNull() + } + + @Test + fun aBulkMoveWorksWhicheverOrderTheRowsComeIn() { + val work = synced() + // The subtask gets the lower id, so it comes first unless the writer reorders. + val child = insert(Tasks.TITLE to "child") + val parent = insert(Tasks.TITLE to "parent") + db.tasks().update(task(child)!!.copy(parentId = parent)) + + val count = writer.update(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to work), "${Tasks.LIST_ID} = ?", arrayOf(home.toString())) + + assertThat(count).isEqualTo(2) + assertThat(task(child)!!.listId).isEqualTo(work) + assertThat(task(parent)!!.listId).isEqualTo(work) + assertThat(task(child)!!.parentId).isEqualTo(parent) + } + + @Test + fun aRowSelectedWithAnArgumentIsWritten() { + val a = insert(Tasks.TITLE to "a") + val b = insert(Tasks.TITLE to "b") + + assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), "${Tasks.ID}=?", arrayOf(b.toString()))).isEqualTo(1) + assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), "${Tasks.ID} = ?", arrayOf("987654"))).isEqualTo(0) + + assertThat(task(a)!!.title).isEqualTo("a") + assertThat(task(b)!!.title).isEqualTo("x") + } + + @Test + fun garbageExceptionDatesAreRejected() { + val series = dailySeries() + + assertThrows(IllegalArgumentException::class.java) { + writer.update(ProviderTable.TASKS, mapOf(Tasks.EXDATE to "tomorrow"), byId(ProviderTable.TASKS, series), null) + } + assertThat(task(series)!!.exdate).isNull() + } + + private fun store() = RoomTasksDataSource(db) + + /** Every column of one published row, as a client that read it would write it back. */ + private fun readRow(table: ProviderTable, id: Long): Map { + val columns = table.columns.map { it.first } + val row = source.rows(table, columns, byId(table, id), null).single() + return columns.zip(row).toMap() - table.idColumn + } + +} + +private object NoProvider : ProviderEnvironment { + override fun packageDeclaring(authority: String): String? = null + override fun isGranted(permission: String): Boolean = false + override fun appLabel(packageName: String): String? = null +} diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/provider/SnapshotDatabaseTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/provider/SnapshotDatabaseTest.kt new file mode 100644 index 0000000..c669613 --- /dev/null +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/provider/SnapshotDatabaseTest.kt @@ -0,0 +1,141 @@ +package de.jeanlucmakiola.agendula.data.tasks.provider + +import android.database.Cursor +import android.os.Build +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks +import org.junit.Assert.assertThrows +import org.junit.Assume.assumeTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** The SQL a client sends runs against the snapshot as it would against OpenTasks. */ +@RunWith(AndroidJUnit4::class) +class SnapshotDatabaseTest { + + private val db = SnapshotDatabase() + + private fun task(id: Long, title: String?, due: Long? = null): ProviderRow = + mapOf(Tasks.ID to id, Tasks.LIST_ID to 1L, Tasks.TITLE to title, Tasks.DUE to due, Tasks.PRIORITY to 0) + + private fun load(vararg tasks: ProviderRow, lists: List = emptyList()) = db.load( + ProviderSnapshot( + ProviderTable.entries.associateWith { + when (it) { + ProviderTable.TASKS -> tasks.toList() + ProviderTable.LISTS -> lists + else -> emptyList() + } + }, + ), + ) + + private fun Cursor.strings(column: String): List = use { + val index = getColumnIndexOrThrow(column) + buildList { while (moveToNext()) add(if (isNull(index)) null else getString(index)) } + } + + @Test + fun aNullProjectionReturnsEveryColumnInTableOrder() { + load(task(1, "a")) + + db.query(ProviderTable.TASKS, null, null, null, null).use { + assertThat(it.columnNames.toList()).containsExactlyElementsIn(ProviderTable.TASKS.columns.map { c -> c.first }) + .inOrder() + assertThat(it.count).isEqualTo(1) + } + } + + @Test + fun selectionArgumentsAndSortOrderApply() { + load(task(1, "b", due = 30), task(2, "a", due = 10), task(3, "c", due = 20), task(4, "skip", due = 5)) + + val titles = db.query(ProviderTable.TASKS, arrayOf(Tasks.TITLE), "${Tasks.TITLE} != ?", arrayOf("skip"), "${Tasks.DUE} ASC") + .strings(Tasks.TITLE) + + assertThat(titles).containsExactly("a", "c", "b").inOrder() + } + + @Test + fun typesSurviveTheCopy() { + load(task(1, null, due = 1_700_000_000_000)) + + db.query(ProviderTable.TASKS, arrayOf(Tasks.TITLE, Tasks.DUE, Tasks.PRIORITY), null, null, null).use { + it.moveToFirst() + assertThat(it.isNull(0)).isTrue() + assertThat(it.getType(1)).isEqualTo(Cursor.FIELD_TYPE_INTEGER) + assertThat(it.getLong(1)).isEqualTo(1_700_000_000_000) + assertThat(it.getInt(2)).isEqualTo(0) + } + } + + @Test + fun anUnsupportedColumnReadsAsNull() { + load(task(1, "a")) + + assertThat(db.query(ProviderTable.TASKS, arrayOf("_sync_id"), null, null, null).strings("_sync_id")) + .containsExactly(null) + } + + @Test + fun anUnknownProjectionColumnIsRejected() { + load(task(1, "a")) + + assertThrows(IllegalArgumentException::class.java) { + db.query(ProviderTable.TASKS, arrayOf("no_such_column"), null, null, null) + } + } + + @Test + fun anUnknownSelectionColumnIsRejected() { + assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) + load(task(1, "a")) + + assertThrows(IllegalArgumentException::class.java) { + db.query(ProviderTable.TASKS, null, "sqlite_master.name IS NOT NULL", null, null) + } + } + + @Test + fun aSelectionCannotBreakOutOfItsWhereClause() { + load(task(1, "a")) + + assertThrows(IllegalArgumentException::class.java) { + db.query(ProviderTable.TASKS, null, "1) UNION SELECT name FROM sqlite_master --", null, null) + } + } + + @Test + fun eachTableAnswersOnlyForItsOwnRows() { + load(task(1, "a"), lists = listOf(mapOf(Lists.ID to 7L, Lists.NAME to "Home"))) + + assertThat(db.query(ProviderTable.LISTS, arrayOf(Lists.NAME), null, null, null).strings(Lists.NAME)) + .containsExactly("Home") + db.query(ProviderTable.INSTANCES, null, null, null, null).use { assertThat(it.count).isEqualTo(0) } + } + + @Test + fun aReloadReplacesTheRowsButNotAnOpenCursor() { + load(task(1, "old")) + val before = db.query(ProviderTable.TASKS, arrayOf(Tasks.TITLE), null, null, null) + + load(task(2, "new")) + + assertThat(before.strings(Tasks.TITLE)).containsExactly("old") + assertThat(db.query(ProviderTable.TASKS, arrayOf(Tasks.TITLE), null, null, null).strings(Tasks.TITLE)) + .containsExactly("new") + } + + @Test + fun anEmptySnapshotEmptiesEveryTable() { + load(task(1, "a")) + + db.load(ProviderSnapshot.EMPTY) + + ProviderTable.entries.forEach { table -> + db.query(table, null, null, null, null).use { assertThat(it.count).isEqualTo(0) } + } + } +} diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSourceTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSourceTest.kt index e4d3d3b..75a3a9e 100644 --- a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSourceTest.kt +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSourceTest.kt @@ -5,7 +5,7 @@ import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.agendula.data.tasks.TaskQuery -import de.jeanlucmakiola.agendula.data.tasks.TaskReminder +import de.jeanlucmakiola.agendula.domain.TaskReminder import de.jeanlucmakiola.agendula.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskStatus import org.junit.After @@ -366,41 +366,23 @@ class RoomTasksDataSourceTest { @Test fun alarmsRoundTripAndReplaceRatherThanAccumulate() { val id = source.insertTask(form(due = now + 1.days)) + val start = TaskReminder(minutesBefore = 10, fromStart = true) // The whole reminder, not just the minute count: collapsing it to a bare - // Int is what fired an imported START-referenced alarm off DUE, and an - // alarm this seam sets from the UI is always due-referenced. - source.setAlarm(id, 30) - assertThat(source.alarms()[id]).isEqualTo(TaskReminder(minutesBefore = 30)) - - source.setAlarm(id, 60) - assertThat(db.alarms().forTask(id)).hasSize(1) - assertThat(source.alarms()[id]).isEqualTo(TaskReminder(minutesBefore = 60)) - - source.setAlarm(id, null) - assertThat(source.alarms()).doesNotContainKey(id) - } - - @Test - fun settingTheEditableReminderLeavesTheOthersAlone() { - val id = source.insertTask(form(due = now + 1.days)) - val start = TaskReminder(minutesBefore = 10, fromStart = true) - source.setReminders(id, listOf(start, TaskReminder(30), TaskReminder(120))) - - source.setAlarm(id, 45) - assertThat(source.reminders()[id]).containsExactly(start, TaskReminder(30), TaskReminder(45)).inOrder() - assertThat(source.alarms()[id]).isEqualTo(TaskReminder(45)) - - source.setAlarm(id, null) + // Int is what fired an imported START-referenced alarm off DUE. + source.setReminders(id, listOf(start, TaskReminder(30))) assertThat(source.reminders()[id]).containsExactly(start, TaskReminder(30)).inOrder() - assertThat(source.alarms()[id]).isEqualTo(TaskReminder(30)) + + source.setReminders(id, listOf(TaskReminder(60))) + assertThat(db.alarms().forTask(id)).hasSize(1) + assertThat(source.reminders()[id]).containsExactly(TaskReminder(60)) } @Test fun forkingAnOccurrenceCarriesTheReminderOntoIt() { val id = source.insertTask(form(due = now + 1.days)) makeRecurring(id, now) - source.setAlarm(id, 30) + source.setReminders(id, listOf(TaskReminder(30))) val target = source.tasks(TaskQuery(listId = listId)) .filter { it.taskId == id } .first { it.distanceFromCurrent == 1 } @@ -417,7 +399,7 @@ class RoomTasksDataSourceTest { val original = db.alarms().forTask(id).single() db.alarms().update(original.copy(raw = "BEGIN:VALARM\r\nX-VENDOR:keep\r\nEND:VALARM\r\n")) - source.updateTask(id, form(title = "renamed", due = now + 2.days), dueReminders = listOf(15, 60)) + source.updateTask(id, form(title = "renamed", due = now + 2.days), reminders = listOf(TaskReminder(15), TaskReminder(60))) val rows = db.alarms().forTask(id) val kept = rows.single { it.minutesBefore == 15 } @@ -429,13 +411,13 @@ class RoomTasksDataSourceTest { } @Test - fun aSaveKeepsABeforeStartReminderTheEditorNeverShows() { + fun aSaveWritesBeforeStartAndBeforeDueRemindersAlike() { val id = source.insertTask(form(due = now + 1.days)) source.setReminders(id, listOf(TaskReminder(10, fromStart = true), TaskReminder(30))) - source.updateTask(id, form(due = now + 1.days), dueReminders = listOf(45)) + source.updateTask(id, form(due = now + 1.days), reminders = listOf(TaskReminder(5, fromStart = true), TaskReminder(45))) - assertThat(source.reminders()[id]).containsExactly(TaskReminder(10, fromStart = true), TaskReminder(45)) + assertThat(source.reminders()[id]).containsExactly(TaskReminder(5, fromStart = true), TaskReminder(45)) } @Test @@ -448,7 +430,7 @@ class RoomTasksDataSourceTest { .filter { it.taskId == id } .first { it.distanceFromCurrent == 1 } - source.updateInstance(id, target.occurrenceStart!!, form(title = "moved").copy(listId = work), listOf(60)) + source.updateInstance(id, target.occurrenceStart!!, form(title = "moved").copy(listId = work), listOf(TaskReminder(60))) // ⚠️ The master is never written, not even briefly: a sync landing in // between would upload the occurrence's reminder for the whole series. @@ -467,7 +449,7 @@ class RoomTasksDataSourceTest { .filter { it.taskId == id } .first { it.distanceFromCurrent == 1 } - val created = source.splitSeries(id, target.occurrenceStart!!, form(title = "later"), listOf(30)) + val created = source.splitSeries(id, target.occurrenceStart!!, form(title = "later"), listOf(TaskReminder(30))) assertThat(created).isNotEqualTo(id) val copy = db.alarms().forTask(created).single() @@ -500,12 +482,12 @@ class RoomTasksDataSourceTest { val completed = occurrences.first { it.distanceFromCurrent == 1 }.occurrenceStart!! val own = occurrences.first { it.distanceFromCurrent == 2 }.occurrenceStart!! source.setCompletedInstance(id, completed, true) - source.updateInstance(id, own, form(title = "own"), listOf(5)) + source.updateInstance(id, own, form(title = "own"), listOf(TaskReminder(5))) // Same times and rule, so the series is not reshaped — that drops its overrides. val first = occurrences.first() val unchanged = form().copy(start = first.start, due = first.due, rrule = "FREQ=WEEKLY") - source.updateSeries(id, first.occurrenceStart!!, unchanged, listOf(60)) + source.updateSeries(id, first.occurrenceStart!!, unchanged, listOf(TaskReminder(60))) fun leads(anchor: Instant) = db.alarms().forTask(db.tasks().override(id, anchor)!!.id).map { it.minutesBefore } assertThat(db.alarms().forTask(id).map { it.minutesBefore }).containsExactly(60) @@ -529,12 +511,12 @@ class RoomTasksDataSourceTest { } @Test - fun settingTheEditableReminderOwesTheServerAPut() { + fun settingRemindersOwesTheServerAPut() { val work = syncedList() val id = source.insertTask(form(due = now + 1.days).copy(listId = work)) db.tasks().update(db.tasks().entity(id)!!.copy(isDirty = false)) - source.setAlarm(id, 30) + source.setReminders(id, listOf(TaskReminder(30))) assertThat(db.tasks().entity(id)!!.isDirty).isTrue() } @@ -567,7 +549,7 @@ class RoomTasksDataSourceTest { fun aForkedOccurrenceCarriesTheSeriesReminder() { val id = source.insertTask(form()) makeRecurring(id, now) - source.setAlarm(id, minutesBeforeDue = 30) + source.setReminders(id, listOf(TaskReminder(30))) val target = source.tasks(TaskQuery(listId = listId)) .filter { it.taskId == id } .first { it.distanceFromCurrent == 1 } @@ -667,4 +649,47 @@ class RoomTasksDataSourceTest { assertThat(thrown).isNotNull() } + + @Test + fun subtaskIdsNameRowsNotOccurrences() { + val parent = source.insertTask(form("parent")) + val longAgo = now - 1000.days + val outOfWindow = db.tasks().insert( + TaskEntity(listId = listId, uid = "old", title = "old", dtstart = longAgo, rrule = "FREQ=DAILY;COUNT=2", parentId = parent), + ) + val series = db.tasks().insert( + TaskEntity(listId = listId, uid = "s", title = "s", dtstart = now, rrule = "FREQ=DAILY;COUNT=3", parentId = parent), + ) + source.setCompletedInstance(series, now, true) + val plain = source.insertTask(form("plain").copy(parentId = parent)) + + assertThat(source.subtaskIds(parent)).containsExactly(outOfWindow, series, plain) + } + + @Test + fun deletingASubtreeEndsOnAParentLoop() { + val a = source.insertTask(form("a")) + val b = source.insertTask(form("b").copy(parentId = a)) + val c = source.insertTask(form("c").copy(parentId = b)) + db.tasks().update(db.tasks().entity(a)!!.copy(parentId = c)) + + source.deleteWithSubtasks(a) + + assertThat(listOf(a, b, c).mapNotNull { db.tasks().entity(it) }).isEmpty() + } + + @Test + fun deletingFollowingFromTheFirstOccurrenceTakesTheWholeSubtree() { + val start = now + val series = db.tasks().insert( + TaskEntity(listId = listId, uid = "s", title = "s", dtstart = start, rrule = "FREQ=DAILY;COUNT=3"), + ) + val child = source.insertTask(form("child").copy(parentId = series)) + val grandchild = source.insertTask(form("grandchild").copy(parentId = child)) + + source.deleteFollowing(series, start) + + assertThat(listOf(series, child, grandchild).mapNotNull { db.tasks().entity(it) }).isEmpty() + } } + diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImportTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImportTest.kt index 0fe2d57..1570592 100644 --- a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImportTest.kt +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImportTest.kt @@ -11,7 +11,7 @@ import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.agendula.data.tasks.ProviderEnvironment import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver import de.jeanlucmakiola.agendula.data.tasks.TaskQuery -import de.jeanlucmakiola.agendula.data.tasks.TaskReminder +import de.jeanlucmakiola.agendula.domain.TaskReminder import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase @@ -294,25 +294,24 @@ private class FakeExternalStore : TasksDataSource { return tasks[listId].orEmpty() } - override fun alarms(): Map = alarms + override fun reminders(): Map> = alarms.mapValues { listOf(it.value) } override fun tasks(query: TaskQuery): List = unused() override fun task(taskId: Long): Task? = unused() override fun subtasks(parentTaskId: Long): List = unused() override fun insertTask(form: TaskForm, reminders: List?): Long = unused() - override fun updateTask(taskId: Long, form: TaskForm, dueReminders: List?) = unused() - override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List?) = + override fun updateTask(taskId: Long, form: TaskForm, reminders: List?) = unused() + override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List?) = unused() - override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) = unused() override fun setReminders(taskId: Long, reminders: List) = unused() override fun setCompleted(taskId: Long, completed: Boolean) = unused() override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) = unused() override fun deleteTask(taskId: Long) = unused() override fun setCancelled(taskId: Long, cancelled: Boolean) = unused() override fun setCancelledInstance(taskId: Long, occurrenceStart: Instant, cancelled: Boolean) = unused() - override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List?) = + override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List?) = unused() - override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List?): Long = + override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List?): Long = unused() override fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant) = unused() override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) = unused() 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 05a7bed..46ac615 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 @@ -4,9 +4,9 @@ import de.jeanlucmakiola.agendula.data.di.IoDispatcher import de.jeanlucmakiola.caldav.AppPassword import de.jeanlucmakiola.caldav.CalDavDiscovery import de.jeanlucmakiola.caldav.CalDavHttp -import de.jeanlucmakiola.caldav.DnsJavaResolver import de.jeanlucmakiola.caldav.NextcloudLoginFlow import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.job import kotlinx.coroutines.withContext import okhttp3.HttpUrl import java.util.concurrent.TimeUnit @@ -75,7 +75,8 @@ class OkHttpCalDavGateway @Inject constructor( val client = credentials?.let { CalDavHttp.authenticated(userAgent, it.username, it.password, it.origin) } ?: CalDavHttp.anonymous(userAgent) - CalDavDiscovery(client, DnsJavaResolver()).discover(target) + val job = coroutineContext.job + CalDavDiscovery(client, SystemDnsResolver { !job.isActive }).discover(target) } override suspend fun startLoginFlow(server: HttpUrl): NextcloudLoginFlow.Flow? = diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/CollectionSyncer.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/CollectionSyncer.kt index abc7dbf..a54e999 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/CollectionSyncer.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/CollectionSyncer.kt @@ -874,11 +874,11 @@ class CollectionSyncer( * [incoming] with each alarm we uploaded ourselves put back to the lead * we hold for it. * - * ⚠️ The wire is coarser than the row: an all-day lead goes out in whole - * days, and a before-start reminder on a task without a start goes out - * before-due. Taking the round trip at its word would quietly turn a - * 15-minute all-day reminder into "on the day", or a before-start one - * into a before-due one, the first time the task is downloaded again. + * ⚠️ The wire is coarser than the row: a lead past the end of its + * anchor is clamped, and a before-start reminder on a task without a + * start goes out before-due. Taking the round trip at its word would + * quietly turn a before-start reminder into a before-due one the first + * time the task is downloaded again. * An alarm that comes back as exactly what we would have sent has not * been changed, so the row's own lead stands. */ diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SystemDnsResolver.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SystemDnsResolver.kt new file mode 100644 index 0000000..29d187b --- /dev/null +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SystemDnsResolver.kt @@ -0,0 +1,103 @@ +package de.jeanlucmakiola.agendula.data.sync + +import android.net.DnsResolver as PlatformDnsResolver +import android.os.CancellationSignal +import android.os.Looper +import de.jeanlucmakiola.caldav.DnsAnswers +import de.jeanlucmakiola.caldav.DnsRecords +import de.jeanlucmakiola.caldav.DnsResolver +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +/** + * SRV/TXT lookups through the system resolver, so discovery uses the current + * network's DNS and honours Private DNS rather than sending plain queries of + * its own. A failed or timed-out lookup reads as no records. + * + * The platform delivers answers through the main looper, so this must run on a + * worker thread; waiting on the main one would deadlock until the timeout. + * The wait gives up as soon as [isCancelled] says so, and answered lookups are + * kept briefly so the authenticated walk does not ask again. + */ +class SystemDnsResolver( + private val isCancelled: () -> Boolean = { false }, +) : DnsResolver { + + override fun lookup(name: String): DnsRecords { + check(!Looper.getMainLooper().isCurrentThread) { "DNS lookup on the main thread" } + val now = System.nanoTime() + cache[name]?.takeIf { now - it.at < CACHE_NANOS }?.let { return it.records } + val srv = Query(name, DnsAnswers.TYPE_SRV) + val txt = Query(name, DnsAnswers.TYPE_TXT) + val answered = try { + val deadline = now + TimeUnit.SECONDS.toNanos(TIMEOUT_SECONDS) + srv.await(deadline, isCancelled) && txt.await(deadline, isCancelled) + } finally { + srv.cancel() + txt.cancel() + } + val records = DnsRecords( + srv.answer()?.let(DnsAnswers::srv).orEmpty(), + txt.answer()?.let(DnsAnswers::txt).orEmpty(), + ) + if (answered) cache[name] = Cached(now, records) + return records + } + + private class Cached(val at: Long, val records: DnsRecords) + + private class Query(name: String, type: Int) { + private val done = CountDownLatch(1) + private val signal = CancellationSignal() + private val result = AtomicReference() + + init { + try { + PlatformDnsResolver.getInstance().rawQuery( + null, + name, + PlatformDnsResolver.CLASS_IN, + type, + PlatformDnsResolver.FLAG_EMPTY, + Runnable::run, + signal, + object : PlatformDnsResolver.Callback { + override fun onAnswer(answer: ByteArray, rcode: Int) { + result.set(answer) + done.countDown() + } + + override fun onError(error: PlatformDnsResolver.DnsException) { + done.countDown() + } + }, + ) + } catch (e: RuntimeException) { + done.countDown() + } + } + + /** Whether the query finished before [deadline] and before [isCancelled]. */ + fun await(deadline: Long, isCancelled: () -> Boolean): Boolean { + while (!isCancelled()) { + val left = deadline - System.nanoTime() + if (left <= 0) return false + if (done.await(minOf(left, SLICE_NANOS), TimeUnit.NANOSECONDS)) return true + } + return false + } + + fun cancel() = signal.cancel() + + fun answer(): ByteArray? = result.get() + } + + private companion object { + const val TIMEOUT_SECONDS = 10L + val SLICE_NANOS = TimeUnit.MILLISECONDS.toNanos(100) + val CACHE_NANOS = TimeUnit.MINUTES.toNanos(1) + val cache = ConcurrentHashMap() + } +} 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 b23322b..1a88396 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 @@ -70,6 +70,7 @@ internal fun AccountDetailScreen( onSignInAgain: (AccountEntity) -> Unit, onOpenStorage: () -> Unit, viewModel: AccountsViewModel, + visible: Boolean, ) { val accounts by viewModel.accounts.collectAsStateWithLifecycle() val usable by viewModel.accountsUsable.collectAsStateWithLifecycle() @@ -90,7 +91,7 @@ internal fun AccountDetailScreen( // shown on the accounts list rather than here. var confirming by remember { mutableStateOf(false) } - CollapsingScaffold(title = identity.title, onBack = onBack) { + CollapsingScaffold(title = identity.title, onBack = onBack, predictiveBack = true, visible = visible) { AccountHero(identity) Spacer(Modifier.height(24.dp)) 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 a45c598..a4467d5 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 @@ -48,6 +48,7 @@ internal fun AccountsScreen( onOpenStorage: () -> Unit, onBack: () -> Unit, viewModel: AccountsViewModel, + visible: Boolean, ) { val accounts by viewModel.accounts.collectAsStateWithLifecycle() val usable by viewModel.accountsUsable.collectAsStateWithLifecycle() @@ -60,6 +61,8 @@ internal fun AccountsScreen( CollapsingScaffold( title = stringResource(R.string.settings_section_accounts), onBack = onBack, + predictiveBack = true, + visible = visible, ) { val loaded = accounts ?: return@CollapsingScaffold 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 c3a5232..7b3c390 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 @@ -300,7 +300,9 @@ private fun StepTitle(step: AddAccountStep) { R.string.add_account_provider_title to R.string.add_account_provider_body is AddAccountStep.PrepareAccess -> step.quirk.setupTitle to R.string.add_account_setup_body - is AddAccountStep.EnterAddress -> if (step.choice.provider?.hosted == true) { + is AddAccountStep.EnterAddress -> if (step.manualServer) { + R.string.add_account_server_title to R.string.add_account_server_fallback_body + } else if (step.asksForEmail) { R.string.add_account_email_title to R.string.add_account_email_body } else { R.string.add_account_server_title to R.string.add_account_server_body 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 d102bee..ef9e76b 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 @@ -77,7 +77,12 @@ sealed interface AddAccountStep { val input: String = "", /** A cause, not a message — the screen owns the wording. */ val error: CalDavDiscovery.Outcome.Cause? = null, - ) : AddAccountStep + /** The email address found no server, so a hosted choice asks for its URL instead. */ + val manualServer: Boolean = false, + ) : AddAccountStep { + /** Whether the field takes an email address rather than a server URL. */ + val asksForEmail: Boolean get() = choice.provider?.hosted == true && !manualServer + } data class Working(val message: AddAccountMessage.Progress) : AddAccountStep @@ -279,6 +284,11 @@ class AddAccountViewModel @Inject constructor( get() = handle[KEY_REAUTH_ACCOUNT] set(value) { handle[KEY_REAUTH_ACCOUNT] = value } + /** The email that found no server; Back returns to it from the server field. */ + private var fallbackEmail: String? + get() = handle[KEY_FALLBACK_EMAIL] + set(value) { handle[KEY_FALLBACK_EMAIL] = value } + /** The username the credentials step starts with on a re-authentication. */ private var suggestedUsername: String get() = handle[KEY_SUGGESTED_USERNAME] ?: "" @@ -370,6 +380,7 @@ class AddAccountViewModel @Inject constructor( // one is not an answer to this one. if (picked != choice) addressInput = "" choice = picked + clearFallback() val quirk = picked.provider?.let(ServerQuirk::forProvider) val setup = quirk?.hasSetupSteps == true updateState { @@ -430,11 +441,10 @@ class AddAccountViewModel @Inject constructor( addressInput = value updateState { it.copy( - step = AddAccountStep.EnterAddress(picked, value), + step = addressStep(value), // The service's own rule outranks the one the typed host implies: // it was chosen deliberately, and it is known before a key is hit. - quirk = picked.provider?.let(ServerQuirk::forProvider) - ?: ServerQuirk.forInput(value), + quirk = quirkFor(value), ) } } @@ -505,13 +515,47 @@ class AddAccountViewModel @Inject constructor( // would make it permanently unreachable. CalDavDiscovery.Outcome.Unauthenticated -> offerSignIn() - is CalDavDiscovery.Outcome.NotCalDav -> backToAddress(outcome.cause) + is CalDavDiscovery.Outcome.NotCalDav -> backToAddressOrServer(outcome.cause) - is CalDavDiscovery.Outcome.Failed -> backToAddress(outcome.cause) + is CalDavDiscovery.Outcome.Failed -> backToAddressOrServer(outcome.cause) } } } + /** + * 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 + * the username. A domain nothing answered for may also be a typo or no + * network, so that message stays visible on the server field. Any other + * failure keeps its own message on the email field. + */ + private fun backToAddressOrServer(cause: CalDavDiscovery.Outcome.Cause) { + val email = ServiceDiscovery.emailOf(typedInput) + if (fallbackEmail != null || email == null || cause !in NO_SERVER_FOUND) { + return backToAddress(cause) + } + fallbackEmail = email + addressInput = "" + updateState { + it.copy( + step = addressStep("", cause.takeIf { it == CalDavDiscovery.Outcome.Cause.UNREACHABLE }), + quirk = quirkFor(""), + ) + } + } + + private fun clearFallback() { + fallbackEmail = null + } + + /** + * The username the credentials step starts with: a re-authentication's, else + * the email that fell back to the server field — or one typed into it. + */ + private fun prefilledUsername(): String = suggestedUsername.ifBlank { + fallbackEmail?.let { ServiceDiscovery.emailOf(typedInput) ?: it }.orEmpty() + } + /** Step 3a → re-run discovery with the password the user typed. */ fun onCredentialsSubmitted() { val step = current.step as? AddAccountStep.EnterCredentials ?: return @@ -549,8 +593,8 @@ class AddAccountViewModel @Inject constructor( CalDavDiscovery.Outcome.Unauthenticated -> credentialsRejected() - is CalDavDiscovery.Outcome.NotCalDav -> backToAddress(outcome.cause) - is CalDavDiscovery.Outcome.Failed -> backToAddress(outcome.cause) + is CalDavDiscovery.Outcome.NotCalDav -> backToAddressOrServer(outcome.cause) + is CalDavDiscovery.Outcome.Failed -> backToAddressOrServer(outcome.cause) } } } @@ -577,7 +621,7 @@ class AddAccountViewModel @Inject constructor( updateState { it.copy( step = AddAccountStep.EnterCredentials( - username = suggestedUsername, + username = prefilledUsername(), error = quirkHint(), ), ) @@ -803,7 +847,7 @@ class AddAccountViewModel @Inject constructor( pollJob?.cancel() updateState { it.copy( - step = AddAccountStep.EnterCredentials(username = suggestedUsername, error = null), + step = AddAccountStep.EnterCredentials(username = prefilledUsername(), error = null), openInBrowser = null, ) } @@ -916,7 +960,17 @@ class AddAccountViewModel @Inject constructor( // Back into the errand when there was one, so the steps can be re-read // without starting the flow again — that is the screen people return to. is AddAccountStep.EnterAddress -> { - updateState { it.copy(step = providerOrSetupStep(), fatal = null) } + val email = fallbackEmail + if (email != null) { + clearFallback() + addressInput = email + typedInput = email + updateState { + it.copy(step = addressStep(email), fatal = null, quirk = quirkFor(email)) + } + } else { + updateState { it.copy(step = providerOrSetupStep(), fatal = null) } + } true } @@ -990,6 +1044,7 @@ class AddAccountViewModel @Inject constructor( hostsNeedingAuth = emptyList() reauthAccountId = null suggestedUsername = "" + fallbackEmail = null current = AddAccountUiState() } @@ -1119,8 +1174,7 @@ class AddAccountViewModel @Inject constructor( reauthenticating = reauthAccountId != null, // Derived rather than saved: it is a pure function of the choice and the // address, both of which are. - quirk = choice?.provider?.let(ServerQuirk::forProvider) - ?: ServerQuirk.forInput(addressInput), + quirk = quirkFor(addressInput), ) /** @@ -1148,8 +1202,8 @@ class AddAccountViewModel @Inject constructor( val credentials = CalDavGateway.Credentials(username, appPassword, root) when (val outcome = gateway.discover(target, credentials)) { is CalDavDiscovery.Outcome.Found -> onDiscovered(outcome, selection) - is CalDavDiscovery.Outcome.NotCalDav -> backToAddress(outcome.cause) - is CalDavDiscovery.Outcome.Failed -> backToAddress(outcome.cause) + is CalDavDiscovery.Outcome.NotCalDav -> backToAddressOrServer(outcome.cause) + is CalDavDiscovery.Outcome.Failed -> backToAddressOrServer(outcome.cause) // The credential we were holding is no longer accepted, which on // this path is indistinguishable from never having had one. is CalDavDiscovery.Outcome.NeedsAuthentication -> { @@ -1250,6 +1304,7 @@ class AddAccountViewModel @Inject constructor( choice = choice ?: ProviderChoice.OtherServer, input = input, error = error, + manualServer = fallbackEmail != null, ) /** What was actually set up, named the way the accounts list will name it. */ @@ -1286,12 +1341,13 @@ class AddAccountViewModel @Inject constructor( updateState { it.copy(step = transform(step)) } } + /** The chosen service's quirk, else the one the typed host implies. */ + private fun quirkFor(input: String): ServerQuirk? = + choice?.provider?.let(ServerQuirk::forProvider) ?: ServerQuirk.forInput(input) + /** The provider-specific reason a correct-looking password gets rejected. */ private fun quirkHint(): AddAccountMessage? = - when ( - val quirk = choice?.provider?.let(ServerQuirk::forProvider) - ?: ServerQuirk.forInput(typedInput) - ) { + when (val quirk = quirkFor(typedInput)) { ServerQuirk.FASTMAIL_APP_PASSWORD, ServerQuirk.ICLOUD_APP_SPECIFIC_PASSWORD, -> AddAccountMessage.Quirk(quirk) @@ -1377,6 +1433,14 @@ class AddAccountViewModel @Inject constructor( const val KEY_SUMMARY_LISTS = "add_account.summary_lists" 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" + + /** 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, + ) /** * What to type for a server, from the principal an account stored. diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddressStep.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddressStep.kt index 2e28328..a8ddfc9 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddressStep.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddressStep.kt @@ -20,7 +20,7 @@ import de.jeanlucmakiola.agendula.R */ @Composable internal fun AddressStep(step: AddAccountStep.EnterAddress, viewModel: AddAccountViewModel) { - val hosted = step.choice.provider?.hosted == true + val hosted = step.asksForEmail Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { FieldRow( label = if (hosted) { @@ -30,7 +30,7 @@ internal fun AddressStep(step: AddAccountStep.EnterAddress, viewModel: AddAccoun }, value = step.input, onValueChange = viewModel::onAddressChanged, - placeholder = step.choice.provider?.primaryDomain?.let { "you@$it" } + placeholder = step.choice.provider?.primaryDomain?.takeIf { hosted }?.let { "you@$it" } ?: stringResource(R.string.add_account_server_hint), error = step.error?.let { stringResource(it.message) }, // An email address is still typed with the URI keyboard: it is the diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/ui/settings/AccountSections.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/settings/AccountSections.kt index e2bb836..8e15c79 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/ui/settings/AccountSections.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/ui/settings/AccountSections.kt @@ -31,8 +31,8 @@ internal fun AccountSections( // Which account the detail screen is showing; the section alone cannot say. var openAccount by rememberSaveable(initialAccountId) { mutableStateOf(initialAccountId) } - // Accounts stays composed under Add account, for the same reason Storage - // stays composed under Export: the deeper screen slides over it. + // Accounts stays composed under Add account, for the same reason Backup + // stays composed under export and import: the deeper screen slides over it. val accountsOpen = section == SettingsSection.Accounts || section?.parent == SettingsSection.Accounts SlideInSection(visible = accountsOpen) { @@ -50,9 +50,11 @@ internal fun AccountSections( onOpenStorage = { onSection(SettingsSection.Storage) }, onBack = { onSection(null) }, viewModel = accountsViewModel, + visible = accountsOpen, ) } - SlideInSection(visible = section == SettingsSection.Account) { + val accountOpen = section == SettingsSection.Account + SlideInSection(visible = accountOpen) { openAccount?.let { id -> AccountDetailScreen( accountId = id, @@ -61,6 +63,7 @@ internal fun AccountSections( onSignInAgain = signInAgain, onOpenStorage = { onSection(SettingsSection.Storage) }, viewModel = accountsViewModel, + visible = accountOpen, ) } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c2fc549..0276b0b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -9,13 +9,26 @@ selected External mode. Both are dangerous-level. StorageMode.OWN needs nothing here: it is a Room database in our own data - directory. Agendula publishes no ContentProvider and declares no - permissions of its own. --> + directory. --> + + + + + + diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/AgendulaApp.kt b/app/src/main/java/de/jeanlucmakiola/agendula/AgendulaApp.kt index 08bb3e4..566b63b 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/AgendulaApp.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/AgendulaApp.kt @@ -16,6 +16,7 @@ import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler import de.jeanlucmakiola.agendula.data.reminders.TaskNotifier import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver import de.jeanlucmakiola.agendula.data.tasks.StartupGate +import de.jeanlucmakiola.agendula.data.tasks.provider.ProviderSnapshotSource import de.jeanlucmakiola.agendula.data.tasks.room.DatabaseCheckpoint import de.jeanlucmakiola.agendula.widget.TaskWidgetUpdater import de.jeanlucmakiola.floret.crash.CrashConfig @@ -87,6 +88,7 @@ class AgendulaApp : Application(), Configuration.Provider { scheduler.sync() } runCatching { entryPoint.taskWidgetUpdater().start() } + runCatching { entryPoint.providerSnapshotSource().start() } runCatching { ReminderMaintenanceWorker.schedule(this@AgendulaApp) } } } @@ -122,5 +124,6 @@ class AgendulaApp : Application(), Configuration.Provider { fun taskNotifier(): TaskNotifier fun channelRefreshers(): Set<@JvmSuppressWildcards ChannelRefresher> fun taskWidgetUpdater(): TaskWidgetUpdater + fun providerSnapshotSource(): ProviderSnapshotSource } } 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 332fe0a..15d6244 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt @@ -14,7 +14,11 @@ import de.jeanlucmakiola.agendula.domain.SavedFilter import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskFormField +import de.jeanlucmakiola.agendula.domain.TaskReminder +import de.jeanlucmakiola.agendula.domain.asTaskReminder +import de.jeanlucmakiola.agendula.domain.asReminderLead import de.jeanlucmakiola.agendula.domain.TaskSortOrder +import de.jeanlucmakiola.floret.reminders.ReminderLead import de.jeanlucmakiola.floret.reminders.ReminderOverride import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec import de.jeanlucmakiola.floret.reminders.applyReminderOverride @@ -57,10 +61,10 @@ data class Settings( val dynamicColor: Boolean = true, /** The list a new task defaults to; `null` = first available. */ val defaultListId: Long? = null, - /** Default reminders, as minutes before due (0 = at due time); empty = none. */ - val defaultReminderMinutes: List = listOf(0), - /** Default reminders for all-day tasks, as days-scale minutes before [allDayReminderMinuteOfDay]. */ - val defaultAllDayReminderMinutes: List = listOf(0), + /** Default reminders (0 before due = at due time); empty = none. */ + val defaultReminders: List = listOf(ReminderLead(0)), + /** Default reminders for all-day tasks, in whole days, rung at [allDayReminderMinuteOfDay]. */ + val defaultAllDayReminders: List = listOf(ReminderLead(0)), /** Local time (minutes from midnight) an all-day task's reminder counts back from. */ val allDayReminderMinuteOfDay: Int = DEFAULT_ALL_DAY_REMINDER_MINUTE, /** Master switch for due reminders; off clears every scheduled alarm. */ @@ -74,13 +78,13 @@ data class Settings( */ val bottomAddBar: Boolean = false, /** - * Per-list overrides of [defaultReminderMinutes]: a list present in the map + * Per-list overrides of [defaultReminders]: a list present in the map * overrides the global default (an empty list = no reminder); absent = * inherit. */ - val perListReminderOverride: Map> = emptyMap(), - /** Per-list overrides of [defaultAllDayReminderMinutes], same shape as [perListReminderOverride]. */ - val perListAllDayReminderOverride: Map> = emptyMap(), + val perListReminderOverride: Map> = emptyMap(), + /** Per-list overrides of [defaultAllDayReminders], same shape as [perListReminderOverride]. */ + val perListAllDayReminderOverride: Map> = emptyMap(), /** Optional edit-form fields shown by default; the rest sit behind "More fields". */ val defaultEditFields: Set = emptySet(), val sortOrder: TaskSortOrder = TaskSortOrder.DUE, @@ -109,16 +113,16 @@ data class Settings( } /** The lead times for a task in [listId]: its override if set, else the global default. */ - fun reminderLeadsFor(listId: Long): List = - perListReminderOverride.reminderLeadsFor(listId, defaultReminderMinutes) + fun reminderLeadsFor(listId: Long): List = + perListReminderOverride.reminderLeadsFor(listId, defaultReminders) /** The lead times for an all-day task in [listId]. */ - fun allDayReminderLeadsFor(listId: Long): List = - perListAllDayReminderOverride.reminderLeadsFor(listId, defaultAllDayReminderMinutes) + fun allDayReminderLeadsFor(listId: Long): List = + perListAllDayReminderOverride.reminderLeadsFor(listId, defaultAllDayReminders) - /** The default leads a task in [listId] takes — the one rule for which default applies. */ - fun defaultLeadsFor(listId: Long, allDay: Boolean): List = - if (allDay) allDayReminderLeadsFor(listId) else reminderLeadsFor(listId) + /** The default reminders a task in [listId] takes — the one rule for which default applies. */ + fun defaultRemindersFor(listId: Long, allDay: Boolean): List = + (if (allDay) allDayReminderLeadsFor(listId) else reminderLeadsFor(listId)).map { it.asTaskReminder() } } /** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */ @@ -134,16 +138,18 @@ class SettingsPrefs @Inject constructor( dynamicColor = p[DYNAMIC_COLOR] ?: true, defaultListId = p[DEFAULT_LIST_ID]?.takeIf { it > 0 }, // The single lead of earlier versions carries over until a list is saved. - defaultReminderMinutes = p[DEFAULT_REMINDERS]?.let(::parseMinutes) - ?: listOf(p[REMINDER_LEAD] ?: 0), - defaultAllDayReminderMinutes = p[DEFAULT_ALL_DAY_REMINDERS]?.let(::parseMinutes) ?: listOf(0), + defaultReminders = p[DEFAULT_REMINDERS]?.let(::parseLeads) + ?: listOf(ReminderLead(p[REMINDER_LEAD] ?: 0)), + defaultAllDayReminders = p[DEFAULT_ALL_DAY_REMINDERS]?.let(::parseLeads)?.inWholeDays() + ?: listOf(ReminderLead(0)), allDayReminderMinuteOfDay = p[ALL_DAY_REMINDER_MINUTE]?.takeIf { it in 0 until 24 * 60 } ?: DEFAULT_ALL_DAY_REMINDER_MINUTE, remindersEnabled = p[REMINDERS_ENABLED] ?: true, showAddSubtaskRow = p[SHOW_ADD_SUBTASK_ROW] ?: true, bottomAddBar = p[BOTTOM_ADD_BAR] ?: false, - perListReminderOverride = reminderCodec.parse(p[LIST_REMINDER_OVERRIDE]), - perListAllDayReminderOverride = reminderCodec.parse(p[LIST_ALL_DAY_REMINDER_OVERRIDE]), + perListReminderOverride = reminderCodec.parseLeads(p[LIST_REMINDER_OVERRIDE]), + perListAllDayReminderOverride = reminderCodec.parseLeads(p[LIST_ALL_DAY_REMINDER_OVERRIDE]) + .mapValues { it.value.inWholeDays() }, defaultEditFields = p[DEFAULT_EDIT_FIELDS].orEmpty() .mapNotNull { name -> runCatching { TaskFormField.valueOf(name) }.getOrNull() } .toSet(), @@ -189,12 +195,12 @@ class SettingsPrefs @Inject constructor( if (id == null) it.remove(DEFAULT_LIST_ID) else it[DEFAULT_LIST_ID] = id } - suspend fun setDefaultReminderMinutes(minutes: List) = dataStore.edit { - it[DEFAULT_REMINDERS] = minutes.normalizeReminders().joinToString(",") + suspend fun setDefaultReminders(leads: List) = dataStore.edit { + it[DEFAULT_REMINDERS] = leads.normalizeReminders().joinToString(",") { lead -> lead.toToken() } } - suspend fun setDefaultAllDayReminderMinutes(minutes: List) = dataStore.edit { - it[DEFAULT_ALL_DAY_REMINDERS] = minutes.normalizeReminders().joinToString(",") + suspend fun setDefaultAllDayReminders(leads: List) = dataStore.edit { + it[DEFAULT_ALL_DAY_REMINDERS] = leads.normalizeReminders().joinToString(",") { lead -> lead.toToken() } } suspend fun setAllDayReminderMinuteOfDay(minuteOfDay: Int) = @@ -247,16 +253,16 @@ class SettingsPrefs @Inject constructor( /** Set (or clear, via [ReminderOverride.Inherit]) a list's reminder override. */ suspend fun setListReminderOverride(listId: Long, override: ReminderOverride) = dataStore.edit { p -> - val current = reminderCodec.parse(p[LIST_REMINDER_OVERRIDE]).toMutableMap() + val current = reminderCodec.parseLeads(p[LIST_REMINDER_OVERRIDE]).toMutableMap() current.applyReminderOverride(listId, override) - p[LIST_REMINDER_OVERRIDE] = reminderCodec.serialize(current) + p[LIST_REMINDER_OVERRIDE] = reminderCodec.serializeLeads(current) } /** Set (or clear) a list's all-day reminder override. */ suspend fun setListAllDayReminderOverride(listId: Long, override: ReminderOverride) = dataStore.edit { p -> - val current = reminderCodec.parse(p[LIST_ALL_DAY_REMINDER_OVERRIDE]).toMutableMap() + val current = reminderCodec.parseLeads(p[LIST_ALL_DAY_REMINDER_OVERRIDE]).toMutableMap() current.applyReminderOverride(listId, override) - p[LIST_ALL_DAY_REMINDER_OVERRIDE] = reminderCodec.serialize(current) + p[LIST_ALL_DAY_REMINDER_OVERRIDE] = reminderCodec.serializeLeads(current) } suspend fun setHiddenFromSmartLists(listId: Long, hidden: Boolean) = dataStore.edit { p -> @@ -334,6 +340,13 @@ class SettingsPrefs @Inject constructor( */ private val reminderCodec = ReminderOverrideCodec.DEFAULT -/** `5,30` → [5, 30]; an empty string is an explicit "no reminder". */ -private fun parseMinutes(stored: String): List = - stored.split(',').mapNotNull { it.trim().toIntOrNull()?.takeIf { m -> m >= 0 } }.normalizeReminders() +/** `s5,30` → [5 before start, 30 before due]; an empty string is an explicit "no reminder". */ +private fun parseLeads(stored: String): List = + stored.split(',').mapNotNull { ReminderLead.parse(it)?.takeIf { lead -> lead.minutes >= 0 } }.normalizeReminders() + +/** + * All-day defaults as whole days. Before 1.2 an all-day default could be hours + * before the all-day time; a lead that is not whole days now names a time of day. + */ +private fun List.inWholeDays(): List = + map { it.asTaskReminder().inWholeDays().asReminderLead() }.normalizeReminders() diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderPlanner.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderPlanner.kt index 30f787c..7049d48 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderPlanner.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderPlanner.kt @@ -1,8 +1,9 @@ package de.jeanlucmakiola.agendula.data.reminders import de.jeanlucmakiola.agendula.data.prefs.Settings -import de.jeanlucmakiola.agendula.data.tasks.TaskReminder +import de.jeanlucmakiola.agendula.domain.AllDayReminder import de.jeanlucmakiola.agendula.domain.Task +import de.jeanlucmakiola.agendula.domain.TaskReminder import de.jeanlucmakiola.agendula.domain.calendarDate import java.time.LocalTime import java.time.ZoneId @@ -36,23 +37,24 @@ internal object ReminderPlanner { // One reminder per *occurrence* and lead: a recurring series yields a row // per occurrence, all sharing a taskId, so this is a Set rather than a // taskId-keyed Map. - .filter { !it.isClosed && it.due != null } + .filter { !it.isClosed && (it.due != null || it.start != null) } .flatMap { task -> - // Reminders set on the task itself win — every one of them, not just - // the one the editor shows; otherwise the task's list may override the - // global lead, or opt out entirely (override = null). All-day tasks - // have their own day-scale defaults. + // Reminders set on the task itself win — every one of them; + // otherwise the task's list may override the global lead, or opt + // out entirely (override = null). All-day tasks have their own + // day-scale defaults. // // ⚠️ Present-but-empty is "none", not "the default": the store's // answer for a task whose alarms another client turned off. A // default here would keep ringing for what the user switched off. - val reminders = perTask[task.taskId] - ?: settings.defaultLeadsFor(task.listId, task.isAllDay).map { TaskReminder(it) } + // A default counts only from a date the task has; its own reminders + // stand in each date for the other when the task lacks it. + val own = perTask[task.taskId] + val reminders = own ?: settings.defaultRemindersFor(task.listId, task.isAllDay) + .filter { (if (it.fromStart) task.start else task.due) != null } + val created = task.created?.toEpochMilliseconds() reminders.mapNotNull { reminder -> - // A stored reminder says what it counts back from. Ours are always - // before due, but an imported dmfs alarm or another client's can be - // before *start*. - val anchor = if (reminder.fromStart) task.start ?: task.due!! else task.due!! + val anchor = if (reminder.fromStart) task.start ?: task.due!! else task.due ?: task.start!! val trigger = triggerAt( anchor, task.isAllDay, @@ -60,14 +62,19 @@ internal object ReminderPlanner { settings.allDayReminderMinuteOfDay, zone, ) + // A default never rings for a moment before the task existed: + // that is a task saved after its default had passed, not a + // reminder missed while the device was off. + if (own == null && created != null && trigger < created) return@mapNotNull null // Dismissed on another client already: firing it here within // the missed-reminder grace would ring for something done with. // - // ⚠️ An all-day reminder is compared from the start of its day, - // not from this device's time of day: other clients ring it at - // midnight, or at their own hour, so a dismissal at 07:00 must - // still silence the 09:00 this device would ring it at. - val earliest = if (task.isAllDay) { + // ⚠️ An all-day reminder without a time of its own is compared + // from the start of its day, not from this device's time of + // day: other clients ring it at midnight, or at their own hour, + // so a dismissal at 07:00 must still silence the 09:00 this + // device would ring it at. + val earliest = if (task.isAllDay && reminder.allDay.minuteOfDay == null) { triggerAt(anchor, true, reminder.minutesBefore, 0, zone) } else { trigger @@ -96,8 +103,9 @@ internal object ReminderPlanner { * * ⚠️ An all-day anchor is UTC midnight of its date (see `AllDayTime.kt`), a * storage convention and not a moment — firing off it rang at 02:00 in - * Berlin and the evening before in New York. It is read as its calendar date - * at [allDayMinuteOfDay] local time instead, and the lead counts back from that. + * Berlin and the evening before in New York. It is read as its calendar + * date instead, and the lead as [TaskReminder.allDay]: whole days ring at + * [allDayMinuteOfDay] local time, anything else at the time it names. */ fun triggerAt( anchor: Instant, @@ -106,12 +114,11 @@ internal object ReminderPlanner { allDayMinuteOfDay: Int, zone: ZoneId, ): Long { - val base = if (allDay) { - val time = LocalTime.of(allDayMinuteOfDay / 60 % 24, allDayMinuteOfDay % 60) - anchor.calendarDate(allDay = true).atTime(time).atZone(zone).toInstant().toEpochMilli() - } else { - anchor.toEpochMilliseconds() - } - return base - leadMinutes.coerceAtLeast(0) * 60_000L + if (!allDay) return anchor.toEpochMilliseconds() - leadMinutes.coerceAtLeast(0) * 60_000L + val (days, minuteOfDay) = AllDayReminder.of(leadMinutes) + val minute = minuteOfDay ?: allDayMinuteOfDay + val time = LocalTime.of(minute / 60 % 24, minute % 60) + return anchor.calendarDate(allDay = true).minusDays(days.toLong()) + .atTime(time).atZone(zone).toInstant().toEpochMilli() } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt index 5bcfc2d..958a0d5 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.agendula.data.tasks +import de.jeanlucmakiola.agendula.domain.TaskReminder import android.content.ContentProviderOperation import android.content.ContentResolver import android.content.ContentUris @@ -85,6 +86,27 @@ class AndroidTasksDataSource @Inject constructor( override fun subtasks(parentTaskId: Long): List = queryInstances("${Tasks.PARENT_ID} = ?", arrayOf(parentTaskId.toString())) + /** + * Read from the `tasks` table, so a recurring subtask with no occurrence in + * the window still counts. All columns and a lenient `_deleted`, as in + * [exportTasks], since the table's shape varies across providers. + */ + override fun subtaskIds(parentTaskId: Long): List = resolver.query( + TasksContract.tasksUri(authority()), + null, + "${Tasks.PARENT_ID} = ? AND (${Tasks.DELETED} IS NULL OR ${Tasks.DELETED} = 0)", + arrayOf(parentTaskId.toString()), + null, + )?.use { c -> + val r = CursorColumnReader(c) + buildList { + while (c.moveToNext()) { + if (r.getLong(Tasks.ORIGINAL_INSTANCE_ID) != null) continue + r.getLong(Tasks.ID)?.takeIf { it != parentTaskId }?.let(::add) + } + } + } ?: emptyList() + private fun queryInstances(selection: String?, args: Array?): List { val uri = TasksContract.instancesUri(authority()) // projection = null (all columns): the instances view differs across @@ -158,22 +180,20 @@ class AndroidTasksDataSource @Inject constructor( ?: throw TaskWriteFailedException("insert task: no id") } - override fun updateTask(taskId: Long, form: TaskForm, dueReminders: List?) { + override fun updateTask(taskId: Long, form: TaskForm, reminders: List?) { val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id) val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null) if (rows == 0) throw TaskWriteFailedException("update task $taskId") setCategories(taskId, form.tags) - writeDueReminders(taskId, dueReminders) + writeReminders(taskId, reminders) } - /** [leads] as the task's before-due reminders; a start-relative one from elsewhere is kept. */ - private fun writeDueReminders(taskId: Long, leads: List?) { - if (leads == null) return - val kept = alarmRows(taskId).map { it.second }.filter { it.fromStart } - setReminders(taskId, kept + leads.distinct().sorted().map { TaskReminder(it) }) + private fun writeReminders(taskId: Long, reminders: List?) { + if (reminders == null) return + setReminders(taskId, reminders) } - override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List?) { + override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List?) { val occurrence = task(seriesId, occurrenceStart) val anchors = resolver.query( taskUri(authority(), seriesId), arrayOf(Tasks.DTSTART, Tasks.DUE), null, null, null, @@ -193,7 +213,7 @@ class AndroidTasksDataSource @Inject constructor( val rows = resolver.update(taskUri(authority(), seriesId), values.toContentValues(), null, null) if (rows == 0) throw TaskWriteFailedException("update series $seriesId") setCategories(seriesId, form.tags) - writeDueReminders(seriesId, dueReminders) + writeReminders(seriesId, reminders) } /** @@ -202,10 +222,10 @@ class AndroidTasksDataSource @Inject constructor( * task. Per-occurrence edits past the split point are dropped rather than * carried over — the provider offers no way to re-parent an exception. */ - override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List?): Long { + override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List?): Long { val spec = seriesSpec(seriesId) ?: throw TaskWriteFailedException("split series $seriesId") if (spec.rrule == null || occurrenceStart <= spec.anchor) { - updateSeries(seriesId, occurrenceStart, form, dueReminders) + updateSeries(seriesId, occurrenceStart, form, reminders) return seriesId } // COUNT counts before EXDATE is applied (RFC 5545 §3.8.5.3). @@ -215,13 +235,12 @@ class AndroidTasksDataSource @Inject constructor( ).size endSeriesBefore(seriesId, spec, occurrenceStart) val rule = form.rrule?.let { if (it == spec.rrule) TaskFormWriter.remainingCount(it, spent) else it } - val reminders = dueReminders?.distinct()?.sorted()?.map { TaskReminder(it) } return insertTask(form.copy(rrule = rule, percentComplete = null), reminders) } override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) { val spec = seriesSpec(seriesId) ?: return - if (spec.rrule == null || occurrenceStart <= spec.anchor) return deleteTask(seriesId) + if (spec.rrule == null || occurrenceStart <= spec.anchor) return deleteWithSubtasks(seriesId) endSeriesBefore(seriesId, spec, occurrenceStart) } @@ -282,15 +301,15 @@ class AndroidTasksDataSource @Inject constructor( resolver.delete(TasksContract.instanceUri(authority(), instanceId), null, null) } - override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List?) { - if (dueReminders == null) return writeInstance(taskId, occurrenceStart, form) + override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List?) { + if (reminders == null) return writeInstance(taskId, occurrenceStart, form) // ⚠️ Written to the series, and then put back. Forking an occurrence // copies the task's *current* properties onto the new override row, so // setting the alarm beforehand is the only way to carry it across — and // leaving it there would silently give every other occurrence in the // series the reminder the user set on one of them. val seriesReminders = alarmRows(taskId).map { it.second } - writeDueReminders(taskId, dueReminders) + writeReminders(taskId, reminders) try { writeInstance(taskId, occurrenceStart, form) } finally { @@ -351,21 +370,6 @@ class AndroidTasksDataSource @Inject constructor( } } - override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) { - val uri = TasksContract.propertiesUri(authority()) - // Replace rather than update: the provider's AlarmHandler re-validates the - // whole row on every update, so a partial edit throws. Only the editable - // (last before-due) row goes — a start-relative or additional alarm - // another client wrote is not ours to drop. - alarmRows(taskId).lastOrNull { !it.second.fromStart }?.let { (propertyId, _) -> - resolver.delete(uri, "${Properties.PROPERTY_ID} = ?", arrayOf(propertyId.toString())) - } - if (minutesBeforeDue != null) { - resolver.insert(uri, TaskWriteMapper.alarmValues(taskId, minutesBeforeDue).toContentValues()) - ?: throw TaskWriteFailedException("set alarm for task $taskId") - } - } - override fun setReminders(taskId: Long, reminders: List) { val uri = TasksContract.propertiesUri(authority()) resolver.delete( @@ -382,9 +386,6 @@ class AndroidTasksDataSource @Inject constructor( override fun reminders(): Map> = readAlarms(null).groupBy({ it.first }) { it.third } - override fun alarms(): Map = - reminders().mapNotNull { (id, list) -> list.editable()?.let { id to it } }.toMap() - /** `(property_id, reminder)` for [taskId]'s alarm rows, in stored order. */ private fun alarmRows(taskId: Long): List> = readAlarms(taskId).map { it.second to it.third } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ModeRoutingTasksDataSource.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ModeRoutingTasksDataSource.kt index e6596b1..cbfd92a 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ModeRoutingTasksDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ModeRoutingTasksDataSource.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.agendula.data.tasks +import de.jeanlucmakiola.agendula.domain.TaskReminder import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskForm @@ -34,24 +35,24 @@ class ModeRoutingTasksDataSource( override fun tasks(query: TaskQuery): List = active().tasks(query) override fun task(taskId: Long): Task? = active().task(taskId) override fun task(taskId: Long, occurrenceStart: Instant?): Task? = active().task(taskId, occurrenceStart) - override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List?) = - active().updateSeries(seriesId, occurrenceStart, form, dueReminders) - override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List?): Long = - active().splitSeries(seriesId, occurrenceStart, form, dueReminders) + override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List?) = + active().updateSeries(seriesId, occurrenceStart, form, reminders) + override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List?): Long = + active().splitSeries(seriesId, occurrenceStart, form, reminders) override fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant) = active().deleteOccurrence(seriesId, occurrenceStart) override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) = active().deleteFollowing(seriesId, occurrenceStart) override fun subtasks(parentTaskId: Long): List = active().subtasks(parentTaskId) + override fun subtaskIds(parentTaskId: Long): List = active().subtaskIds(parentTaskId) + override fun deleteWithSubtasks(taskId: Long) = active().deleteWithSubtasks(taskId) override fun insertTask(form: TaskForm, reminders: List?): Long = active().insertTask(form, reminders) - override fun updateTask(taskId: Long, form: TaskForm, dueReminders: List?) = - active().updateTask(taskId, form, dueReminders) + override fun updateTask(taskId: Long, form: TaskForm, reminders: List?) = + active().updateTask(taskId, form, reminders) - override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List?) = - active().updateInstance(taskId, occurrenceStart, form, dueReminders) + override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List?) = + active().updateInstance(taskId, occurrenceStart, form, reminders) - override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) = active().setAlarm(taskId, minutesBeforeDue) - override fun alarms(): Map = active().alarms() override fun reminders(): Map> = active().reminders() override fun setReminders(taskId: Long, reminders: List) = active().setReminders(taskId, reminders) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StoredReminders.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StoredReminders.kt new file mode 100644 index 0000000..f67ffcc --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StoredReminders.kt @@ -0,0 +1,101 @@ +package de.jeanlucmakiola.agendula.data.tasks + +import de.jeanlucmakiola.agendula.data.prefs.Settings +import de.jeanlucmakiola.agendula.data.reminders.ReminderPlanner +import de.jeanlucmakiola.agendula.domain.Task +import de.jeanlucmakiola.agendula.domain.TaskForm +import de.jeanlucmakiola.agendula.domain.TaskReminder +import java.time.ZoneId +import kotlin.time.Instant + +internal object StoredReminders { + + /** + * The reminders to store for [form], given the task as it was. + * + * A date the task gets in this write brings the list's or the app's + * default for it *as the task's own reminders*. The default is this + * device's setting, which nothing else can see; stored on the task, it + * travels with it to the server and fires on every client. The cost is + * tasks.org's: changing the default later does not reach the tasks that + * already have it. + * + * A task getting its first date takes the defaults only when the form + * names no reminder; one that already had a date takes those of the new + * date that no reminder of the form counts from. Otherwise the form is + * kept, so an edit that clears every reminder means "use the default + * here" as before. + * + * The form's reminders are already of its kind, timed or all-day — see + * [switched]. Reminders the editor [chose][TaskForm.remindersChosen] are + * kept as they are: it showed these defaults already. + */ + fun of(form: TaskForm, current: Task?, settings: Settings, now: Long, zone: ZoneId): List { + val wanted = form.reminders.map { it.lead }.distinct() + if (form.remindersChosen) return wanted + return withDefaults(form, current?.start, current?.due, settings, now, zone) + } + + /** [of] for a task whose dates were [start] and [due], whatever the form chose. */ + fun withDefaults( + form: TaskForm, + start: Instant?, + due: Instant?, + settings: Settings, + now: Long, + zone: ZoneId, + ): List { + val wanted = form.reminders.map { it.lead }.distinct() + val had = anchorsOf(start, due) + val gained = anchorsOf(form.start, form.due) - had + if (gained.isEmpty()) return wanted + if (had.isEmpty() || wanted.isEmpty()) return wanted.ifEmpty { defaultsFor(form, settings, now, zone) } + val named = wanted.map { it.fromStart }.toSet() + return wanted + defaultsFor(form, settings, now, zone).filter { it.fromStart in gained && it.fromStart !in named } + } + + /** + * [reminders] for [form] once it has switched between timed and all-day. + * Reminders that are exactly the old kind's default become the new kind's; + * the rest keep what still means something: whole days, rounded to them on + * an all-day task. + */ + fun switched( + reminders: List, + form: TaskForm, + settings: Settings, + now: Long, + zone: ZoneId, + ): List { + if (reminders.isEmpty()) return reminders + if (reminders.toSet() == settings.defaultRemindersFor(form.listId, !form.isAllDay).toSet()) { + return defaultsFor(form, settings, now, zone) + } + val kept = if (form.isAllDay) { + reminders.map { it.inWholeDays() } + } else { + reminders.filter { it.allDay.minuteOfDay == null } + } + return kept.distinct().ifEmpty { defaultsFor(form, settings, now, zone) } + } + + /** The dates a reminder can count from, as [TaskReminder.fromStart]. */ + private fun anchorsOf(start: Any?, due: Any?): Set = + setOfNotNull(true.takeIf { start != null }, false.takeIf { due != null }) + + /** + * The defaults [form] takes: those counting from a date it has. A one-off + * task leaves out what is already past — a start of "now" would ring at once. + */ + private fun defaultsFor(form: TaskForm, settings: Settings, now: Long, zone: ZoneId): List = + settings.defaultRemindersFor(form.listId, form.isAllDay).filter { reminder -> + val anchor = (if (reminder.fromStart) form.start else form.due) ?: return@filter false + form.rrule != null || ReminderPlanner.triggerAt( + anchor, + form.isAllDay, + reminder.minutesBefore, + settings.allDayReminderMinuteOfDay, + zone, + ) >= now + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksContract.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksContract.kt index 75f68ee..f15489a 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksContract.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksContract.kt @@ -86,6 +86,12 @@ object TasksContract { const val UID = "_uid" const val DELETED = "_deleted" + + const val HAS_ALARMS = "has_alarms" + const val ORIGINAL_INSTANCE_ALLDAY = "original_instance_allday" + const val LIST_OWNER = "list_owner" + const val LIST_ACCESS_LEVEL = "list_access_level" + const val VISIBLE = "visible" } /** @@ -103,6 +109,7 @@ object TasksContract { const val INSTANCE_DUE_SORTING = "instance_due_sorting" const val DISTANCE_FROM_CURRENT = "distance_from_current" const val IS_RECURRING = "is_recurring" + const val INSTANCE_DURATION = "instance_duration" /** * The occurrence's `RECURRENCE-ID` — the time this occurrence was @@ -118,6 +125,10 @@ object TasksContract { const val PROPERTY_ID = "property_id" const val TASK_ID = "task_id" const val MIMETYPE = "mimetype" + const val DATA0 = "data0" + const val DATA1 = "data1" + const val DATA2 = "data2" + const val DATA3 = "data3" } /** @@ -164,6 +175,9 @@ object TasksContract { /** `data1` — the tag as spelled. */ const val NAME = "data1" + + /** `data2` — the tag's colour, if any. */ + const val COLOR = "data2" } fun propertiesUri(authority: String): Uri = "content://$authority/${Properties.PATH}".toUri() 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 3a3987a..fe40f82 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksDataSource.kt @@ -3,38 +3,9 @@ package de.jeanlucmakiola.agendula.data.tasks import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskList +import de.jeanlucmakiola.agendula.domain.TaskReminder import kotlin.time.Instant -/** - * A stored reminder: how long before, and what it counts back from. - * - * [fromStart] matters because both stores can hold a `START`-referenced alarm — - * the dmfs import preserves one, and an external provider's other clients write - * them — while Agendula's own UI only ever sets a before-due lead. Collapsing it - * to a number here is what silently fired those reminders off the wrong anchor. - */ -data class TaskReminder( - val minutesBefore: Int, - val fromStart: Boolean = false, - /** - * When another client last dismissed this alarm (RFC 9074 `ACKNOWLEDGED`): - * a trigger at or before it has been dealt with and is not fired here. - * Never part of what a reminder *is* — see [lead]. - */ - val acknowledgedAt: Instant? = null, -) { - /** The reminder without its bookkeeping, for comparing two of them. */ - val lead: TaskReminder get() = if (acknowledgedAt == null) this else copy(acknowledgedAt = null) -} - -/** - * The one reminder Agendula's single-reminder editor shows and writes: the last - * before-due one, which is where a write lands, so it stays the same one across - * saves. Anything else a task carries — a start-relative alarm, or the extra ones - * another client or the dmfs import brought — is left to ride along. - */ -fun List.editable(): TaskReminder? = lastOrNull { !it.fromStart } - /** What to fetch from the provider. Smart-list date logic is applied above this. */ data class TaskQuery( val listId: Long? = null, @@ -58,17 +29,38 @@ interface TasksDataSource { fun task(taskId: Long, occurrenceStart: Instant?): Task? = task(taskId) fun subtasks(parentTaskId: Long): List + /** + * The task rows directly under [parentTaskId]: one id per subtask, its series + * master when it recurs, whether or not any occurrence falls in the window + * [subtasks] expands. + */ + fun subtaskIds(parentTaskId: Long): List = + subtasks(parentTaskId).map { it.seriesId ?: it.taskId }.distinct().filter { it != parentTaskId } + + /** + * Delete [taskId] and its subtasks, all the way down; left behind they would + * hang off a tombstone. Each task once, so a parent loop an import brought in + * ends instead of recursing forever. + */ + fun deleteWithSubtasks(taskId: Long) { + val seen = HashSet() + fun delete(id: Long) { + if (!seen.add(id)) return + subtaskIds(id).forEach(::delete) + deleteTask(id) + } + delete(taskId) + } + /** * The write paths take the task's reminders in the same write as the task * itself, so a sync can never upload one without the other. * - * [insertTask] takes the new task's [reminders] whole. The updates take the - * before-due leads as `dueReminders`, and `null` leaves the reminders - * alone; a start-relative reminder the task already carries is kept — the - * editor never shows one, so it never sends one. + * Each takes the task's whole set of [reminders], before-start and + * before-due alike, and `null` leaves the stored ones alone. */ fun insertTask(form: TaskForm, reminders: List? = null): Long - fun updateTask(taskId: Long, form: TaskForm, dueReminders: List? = null) + fun updateTask(taskId: Long, form: TaskForm, reminders: List? = null) /** * Update a single occurrence of a recurring task, addressed by the task row and @@ -81,27 +73,19 @@ interface TasksDataSource { * instance row id keeps this seam independent of any one store's row * numbering; External mode maps it back to an instance row itself. */ - fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List? = null) - /** - * Set (or clear, with `null`) the task's [editable] reminder lead, stored as an - * Alarm property row. Every other reminder on the task is left untouched. The - * provider never fires it — [de.jeanlucmakiola.agendula.data.reminders - * .ReminderScheduler] does — but persisting it here is what syncs the lead and - * shares it with other OpenTasks clients. - */ - fun setAlarm(taskId: Long, minutesBeforeDue: Int?) + fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List? = null) /** * Edit the whole series [seriesId] through its occurrence at [occurrenceStart]: * content from [form], and the series moved by however far that occurrence moved. */ - fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List? = null) + fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List? = null) /** * "This and following": end [seriesId] before [occurrenceStart] and continue * from there as a new series built from [form]. Returns the new series' id. */ - fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List? = null): Long + fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List? = null): Long /** Delete one generated occurrence of [seriesId] (an `EXDATE`). */ fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant) @@ -118,10 +102,7 @@ interface TasksDataSource { * scheduler. A task missing from the map takes the default; one present * with an empty list has none (see `TaskEntity.remindersOff`). */ - fun reminders(): Map> = alarms().mapValues { listOf(it.value) } - - /** Every task's [editable] reminder, by task id. */ - fun alarms(): Map + fun reminders(): Map> /** * Every task in [listId] read from the **`tasks` table**, for export. Masters, diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepository.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepository.kt index b607cf2..347671e 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepository.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepository.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.agendula.data.tasks +import de.jeanlucmakiola.agendula.domain.TaskReminder import de.jeanlucmakiola.agendula.domain.SavedFilter import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskDetail @@ -82,13 +83,6 @@ interface TasksRepository { scope: RecurringScope = RecurringScope.AllOccurrences, ) - /** - * The per-task reminder lead in minutes before due, or `null` if the task has - * none (in which case the list's / global setting applies). Read when the edit - * form loads so saving can't silently drop it. - */ - suspend fun reminderFor(taskId: Long): Int? - /** Every reminder stored on the task, in order — including start-relative ones. */ suspend fun remindersFor(taskId: Long): List diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt index 70991c5..5f77c29 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.agendula.data.tasks +import de.jeanlucmakiola.agendula.domain.TaskReminder import de.jeanlucmakiola.agendula.data.di.IoDispatcher import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.floret.time.DayWindow @@ -110,40 +111,17 @@ class TasksRepositoryImpl @Inject constructor( override suspend fun createTask(form: TaskForm): Long = withContext(io) { - dataSource.insertTask(form, dueRemindersFor(form, current = null).distinct().sorted().map { TaskReminder(it) }) + dataSource.insertTask(form, storedRemindersFor(form, current = null)) } - /** - * The before-due leads to store for [form], given the task as it was. - * - * A task that gets its due date in this write and names no reminder of its - * own takes the list's or the app's default *as its own reminders*. The - * default is this device's setting, which nothing else can see; stored on - * the task, it travels with it to the server and fires on every client. - * The cost is tasks.org's: changing the default later does not reach the - * tasks that already have it. - * - * A task that already had a due date keeps what the form says, so an - * edit that clears every reminder means "use the default here" as before — - * with one exception. Switching between timed and all-day swaps the - * default it was given for the other kind's, as long as the reminders are - * still exactly that default: a timed 15 minutes is no lead for an all-day - * task, which the planner used to sort out by itself while the default - * stayed unstored. - */ - private suspend fun dueRemindersFor(form: TaskForm, current: Task?): List { - if (form.due == null) return form.reminders - val hadDue = current?.due != null - if (!hadDue) { - if (form.reminders.isNotEmpty()) return form.reminders - return settingsPrefs.settings.first().defaultLeadsFor(form.listId, form.isAllDay) - } - if (current.isAllDay == form.isAllDay || form.reminders.isEmpty()) return form.reminders - val settings = settingsPrefs.settings.first() - val previous = settings.defaultLeadsFor(current.listId, current.isAllDay) - return if (form.reminders.sorted() == previous.sorted()) settings.defaultLeadsFor(form.listId, form.isAllDay) - else form.reminders - } + private suspend fun storedRemindersFor(form: TaskForm, current: Task?): List = + StoredReminders.of( + form, + current, + settingsPrefs.settings.first(), + Clock.System.now().toEpochMilliseconds(), + ZoneId.systemDefault(), + ) override suspend fun duplicateTask(taskId: Long, occurrenceStart: Instant?): Long = withContext(io) { @@ -179,9 +157,6 @@ class TasksRepositoryImpl @Inject constructor( return id } - override suspend fun reminderFor(taskId: Long): Int? = - withContext(io) { runCatching { dataSource.alarms()[taskId]?.minutesBefore }.getOrNull() } - override suspend fun remindersFor(taskId: Long): List = withContext(io) { runCatching { dataSource.reminders()[taskId].orEmpty() }.getOrDefault(emptyList()) } @@ -205,7 +180,7 @@ class TasksRepositoryImpl @Inject constructor( val seen = current?.lastModified if (seen != null && seen != expectedLastModified) throw TaskConflictException(taskId) } - val reminders = dueRemindersFor(form, current) + val reminders = storedRemindersFor(form, current) val seriesId = current?.seriesId val occurrence = current?.occurrenceStart if (seriesId != null && occurrence != null) { @@ -260,7 +235,7 @@ class TasksRepositoryImpl @Inject constructor( val current = dataSource.task(taskId, occurrenceStart) val seriesId = current?.seriesId val occurrence = current?.occurrenceStart - if (seriesId == null || occurrence == null) return@withContext deleteWithSubtasks(taskId) + if (seriesId == null || occurrence == null) return@withContext dataSource.deleteWithSubtasks(taskId) when (scope) { RecurringScope.ThisOccurrence -> // An override row deletes itself into an EXDATE; a generated @@ -268,18 +243,10 @@ class TasksRepositoryImpl @Inject constructor( if (current.taskId != seriesId) dataSource.deleteTask(current.taskId) else dataSource.deleteOccurrence(seriesId, occurrence) RecurringScope.ThisAndFollowing -> dataSource.deleteFollowing(seriesId, occurrence) - RecurringScope.AllOccurrences -> deleteWithSubtasks(seriesId) + RecurringScope.AllOccurrences -> dataSource.deleteWithSubtasks(seriesId) } } - /** A parent goes with its subtasks; left behind they would hang off a tombstone. */ - private fun deleteWithSubtasks(taskId: Long) { - dataSource.subtasks(taskId).map { it.taskId }.distinct() - .filter { it != taskId } - .forEach { dataSource.deleteTask(it) } - dataSource.deleteTask(taskId) - } - override suspend fun createLocalList(name: String, color: Int): Long = withContext(io) { dataSource.createLocalList(name, color) } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ical/AlarmCodec.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ical/AlarmCodec.kt index 80de961..b31dbbc 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ical/AlarmCodec.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ical/AlarmCodec.kt @@ -10,6 +10,8 @@ import de.jeanlucmakiola.agendula.domain.ical.ICalParser import de.jeanlucmakiola.agendula.domain.ical.ICalProperty import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer import de.jeanlucmakiola.agendula.domain.ical.ICalValues +import de.jeanlucmakiola.agendula.domain.DAY_MINUTES +import de.jeanlucmakiola.agendula.domain.MIN_ALL_DAY_LEAD import kotlin.math.roundToLong import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -31,8 +33,9 @@ import kotlin.time.Instant * - location alarms (`X-APPLE-PROXIMITY`, RFC 9074 `PROXIMITY`, a nested * component) and Apple's `19760401T005545Z` sentinel trigger they carry; * - repeating alarms (`REPEAT`/`DURATION`) and RFC 9074 snoozes (`RELATED-TO`); - * - a trigger *after* its anchor. The planner coerces a negative lead to 0, so - * it would fire at the wrong time; + * - a trigger *after* its anchor — on an all-day task, after the end of its + * day. The planner coerces a negative lead to 0, so it would fire at the + * wrong time; * - an absolute trigger on a recurring task, or on one with no `DUE` to count * back from. * @@ -50,19 +53,18 @@ import kotlin.time.Instant * `RELATED=END` counts from `DUE`. `RELATED=START` (the default) counts from * `DTSTART` — except when `DTSTART` equals `DUE`, or when there is no * `DTSTART` but there is a `DUE`. Apple writes the latter shape (against §3.8.6.3, - * which wants `DTSTART` present), and reading it as a before-due reminder is - * what makes it editable here: the editor only shows before-due rows. + * which wants `DTSTART` present). * * ## All-day tasks * - * The local fire time of day is a device preference this mapper cannot see, so - * an all-day lead is whole days, written `-PD` against `DUE`'s midnight. Read - * back, any trigger on an all-day task rounds to the nearest whole day. + * A relative trigger counts from the date's midnight, so the lead is the + * reminder as [de.jeanlucmakiola.agendula.domain.TaskReminder.allDay] reads it: + * `-P1D` is a day before at this device's all-day time, which this mapper cannot + * see, and `PT9H` is 09:00 on the day. An absolute trigger has no zone to read + * a time of day in, so it rounds to the nearest whole day. */ object AlarmCodec { - private const val DAY_MINUTES = 24 * 60 - /** Apple's placeholder trigger for location alarms. Never a real time. */ private const val APPLE_SENTINEL = "19760401T005545Z" @@ -114,16 +116,11 @@ object AlarmCodec { else -> return null } val before = -seconds - val minutes = if (task.isAllDay) { - // Rounded before the sign check: `PT9H` on a date-only DUE is - // "09:00 on the day", which is this device's "on the day". - roundToDays(before) - } else { - // A lead in seconds is not one the planner can reproduce. - if (before % 60 != 0L) return null - before / 60 - } - return minutes.toIntOrNull()?.let { Lead(it, reference) } + // A lead in seconds is not one the planner can reproduce. + if (before % 60 != 0L) return null + val minutes = before / 60 + val latest = if (task.isAllDay) MIN_ALL_DAY_LEAD.toLong() else 0L + return minutes.takeIf { it in latest..Int.MAX_VALUE }?.let { Lead(it.toInt(), reference) } } private fun absoluteLead(trigger: ICalProperty, task: TaskEntity): Lead? { @@ -143,6 +140,17 @@ object AlarmCodec { return minutes.toIntOrNull()?.let { Lead(it, AlarmReference.DUE) } } + /** + * [read] as builds before 1.2 stored it on an all-day task: rounded to whole + * days. Such a row keeps the other client's time of day instead of overwriting it. + */ + private fun readAsWholeDays(read: Lead, task: TaskEntity): Lead? = + if (task.isAllDay && read.minutesBefore % DAY_MINUTES != 0) { + read.copy(minutesBefore = roundToDays(read.minutesBefore * 60L).coerceAtLeast(0).toInt()) + } else { + null + } + /** Nearest whole day, in minutes. */ private fun roundToDays(seconds: Long): Long = (seconds / 86_400.0).roundToLong() * DAY_MINUTES @@ -156,10 +164,10 @@ object AlarmCodec { * too — because `RELATED=END` without `DUE` is a resource the server * refuses ([VTodoMapper.validate]). * - * ⚠️ A before-start row on a task that has lost its start is written - * against `DUE`, which is what the planner fires it from. Leaving it out - * would drop it from every other client, and the next download would then - * take it off this device too. + * ⚠️ A row whose anchor the task has lost is written against the other + * date, which is what the planner fires it from. Leaving it out would drop + * it from every other client, and the next download would then take it off + * this device too. * * [now] marks an authored alarm `ACKNOWLEDGED` once it is past even the * planner's missed-reminder grace, so a client that honours RFC 9074 does @@ -167,11 +175,15 @@ object AlarmCodec { * next download and would silence the late reminder this device still owes. */ fun toVAlarm(row: TaskAlarmEntity, task: TaskEntity, now: Instant): ICalComponent? { - val reference = if (row.reference == AlarmReference.START && task.dtstart == null) AlarmReference.DUE - else row.reference + val reference = when { + row.reference == AlarmReference.START && task.dtstart == null -> AlarmReference.DUE + row.reference == AlarmReference.DUE && task.due == null -> AlarmReference.START + else -> row.reference + } val lead = Lead(row.minutesBefore, reference) row.raw?.let(::parse)?.let { original -> - if (resolve(original, task) == lead) return original + val read = resolve(original, task) + if (read == lead || read != null && readAsWholeDays(read, task) == lead) return original } val anchor = when (reference) { @@ -179,12 +191,8 @@ object AlarmCodec { AlarmReference.START -> task.dtstart } ?: return null val related = if (reference == AlarmReference.DUE) "END" else "START" - val minutes = row.minutesBefore.coerceAtLeast(0).toLong() - val seconds = if (task.isAllDay) { - -((minutes + DAY_MINUTES / 2) / DAY_MINUTES) * 86_400 - } else { - -minutes * 60 - } + val minutes = row.minutesBefore.coerceAtLeast(if (task.isAllDay) MIN_ALL_DAY_LEAD else 0).toLong() + val seconds = -minutes * 60 val properties = mutableListOf( ICalProperty("UID", value = row.uid), @@ -202,13 +210,18 @@ object AlarmCodec { /** * What [row] reads back as once it has been to the server and back — not - * always the row itself, because the wire is coarser: an all-day lead is - * whole days, and a before-start row on a task without a start goes out - * before-due. A download that brings back exactly this has not changed the + * always the row itself, because the wire is coarser: a lead past the end + * of its anchor is clamped, and a before-start row on a task without a + * start goes out before-due. A download that brings back exactly this has not changed the * reminder, and the row's own lead must be kept rather than taken from it. + * + * ⚠️ As authored from the row, not re-emitted from [TaskAlarmEntity.raw]: + * a row rounded to whole days before 1.2 re-emits a time of day it does + * not hold, and would keep its rounded lead against the very alarm that + * carries it. */ fun wireLead(row: TaskAlarmEntity, task: TaskEntity): Lead? = - toVAlarm(row, task, Instant.DISTANT_PAST)?.let { resolve(it, task) } + toVAlarm(row.copy(raw = null), task, Instant.DISTANT_PAST)?.let { resolve(it, task) } private fun parse(raw: String): ICalComponent? = runCatching { ICalParser.parse(raw) }.getOrNull()?.takeIf { it.name.equals("VALARM", ignoreCase = true) } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/AgendulaTaskProvider.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/AgendulaTaskProvider.kt new file mode 100644 index 0000000..99b9516 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/AgendulaTaskProvider.kt @@ -0,0 +1,99 @@ +package de.jeanlucmakiola.agendula.data.tasks.provider + +import android.content.ContentProvider +import android.content.ContentProviderOperation +import android.content.ContentProviderResult +import android.content.ContentUris +import android.content.ContentValues +import android.content.UriMatcher +import android.database.Cursor +import android.net.Uri +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent + +/** + * Agendula's own store, published under the OpenTasks contract (`tasklists`, + * `tasks`, `instances`, `properties`) at `.tasks`, so an app that + * already works with OpenTasks or tasks.org works with Agendula by swapping the + * authority. Reads are guarded by `.permission.READ_TASKS`, writes + * by `.permission.WRITE_TASKS`; [ProviderWriter] applies them. See + * `docs/CONTENT_PROVIDER.md`. + */ +class AgendulaTaskProvider : ContentProvider() { + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface ProviderEntryPoint { + fun snapshotSource(): ProviderSnapshotSource + fun writer(): ProviderWriter + } + + private val entryPoint by lazy { + EntryPointAccessors.fromApplication(requireNotNull(context), ProviderEntryPoint::class.java) + } + private val source: ProviderSnapshotSource by lazy { entryPoint.snapshotSource() } + private val writer: ProviderWriter by lazy { entryPoint.writer() } + + private val matcher by lazy { + UriMatcher(UriMatcher.NO_MATCH).apply { + ProviderTable.entries.forEach { table -> + addURI(source.authority, table.path, table.ordinal * 2) + addURI(source.authority, "${table.path}/#", table.ordinal * 2 + 1) + } + } + } + + override fun onCreate(): Boolean = true + + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String?, + ): Cursor { + val (table, id) = match(uri) + return source.query(table, projection, table.selectionFor(id, selection), selectionArgs, sortOrder).apply { + setNotificationUri(requireNotNull(context).contentResolver, uri) + } + } + + override fun getType(uri: Uri): String { + val (table, id) = match(uri) + return table.mimeType(single = id != null) + } + + override fun insert(uri: Uri, values: ContentValues?): Uri { + val (table, id) = match(uri) + require(id == null) { "Insert into ${table.path}, not into one of its rows" } + return ContentUris.withAppendedId(uri, writer.insert(table, values.toMap())) + } + + override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?): Int { + val (table, id) = match(uri) + return writer.update(table, values.toMap(), table.selectionFor(id, selection), selectionArgs) + } + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int { + val (table, id) = match(uri) + return writer.delete(table, table.selectionFor(id, selection), selectionArgs) + } + + /** All or nothing, as against OpenTasks, and observers hear of it once. */ + override fun applyBatch(operations: ArrayList): Array = + writer.batch { super.applyBatch(operations) } + + private fun match(uri: Uri): Pair { + val code = matcher.match(uri) + require(code != UriMatcher.NO_MATCH) { "Unknown URI $uri" } + val table = ProviderTable.entries[code / 2] + if (code % 2 == 0) return table to null + val id = requireNotNull(uri.lastPathSegment?.toLongOrNull()) { "Unknown URI $uri" } + return table to id + } + + private fun ContentValues?.toMap(): ProviderValues = + this?.keySet()?.associateWith { get(it) }.orEmpty() +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderSchema.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderSchema.kt new file mode 100644 index 0000000..1f905f3 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderSchema.kt @@ -0,0 +1,116 @@ +package de.jeanlucmakiola.agendula.data.tasks.provider + +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks + +/** One table [AgendulaTaskProvider] serves, with its columns in cursor order. */ +enum class ProviderTable(val path: String, val columns: List>) { + LISTS( + Lists.PATH, + listOf( + Lists.ID to "INTEGER PRIMARY KEY", + Lists.NAME to "TEXT", + Lists.COLOR to "INTEGER", + Lists.ACCESS_LEVEL to "INTEGER", + Lists.VISIBLE to "INTEGER", + Lists.SYNC_ENABLED to "INTEGER", + Lists.OWNER to "TEXT", + Lists.ACCOUNT_NAME to "TEXT", + Lists.ACCOUNT_TYPE to "TEXT", + ), + ), + TASKS(Tasks.PATH, listOf(Tasks.ID to "INTEGER PRIMARY KEY") + TASK_COLUMNS), + INSTANCES( + Instances.PATH, + listOf( + Tasks.ID to "INTEGER PRIMARY KEY", + Instances.TASK_ID to "INTEGER", + Instances.INSTANCE_START to "INTEGER", + Instances.INSTANCE_START_SORTING to "INTEGER", + Instances.INSTANCE_DUE to "INTEGER", + Instances.INSTANCE_DUE_SORTING to "INTEGER", + Instances.INSTANCE_DURATION to "INTEGER", + Instances.INSTANCE_ORIGINAL_TIME to "INTEGER", + Instances.DISTANCE_FROM_CURRENT to "INTEGER", + Instances.IS_RECURRING to "INTEGER", + ) + TASK_COLUMNS, + ), + PROPERTIES( + Properties.PATH, + listOf( + Properties.PROPERTY_ID to "INTEGER PRIMARY KEY", + Properties.TASK_ID to "INTEGER", + Properties.MIMETYPE to "TEXT", + Properties.DATA0 to "TEXT", + Properties.DATA1 to "TEXT", + Properties.DATA2 to "TEXT", + Properties.DATA3 to "TEXT", + ), + ), + ; + + val idColumn: String get() = columns.first().first + + /** [selection] narrowed to the row [id] names, for a `/` URI. */ + fun selectionFor(id: Long?, selection: String?): String? = + if (id == null) selection else listOfNotNull("$idColumn = $id", selection?.let { "($it)" }).joinToString(" AND ") + + /** OpenTasks' MIME type for a whole table, or for one row of it. */ + fun mimeType(single: Boolean): String = + "${if (single) "vnd.android.cursor.item" else "vnd.android.cursor.dir"}/org.dmfs.tasks.$path" +} + +/** Every `tasks` column but `_id`; the instances view carries them too. */ +private val TASK_COLUMNS: List> + get() = listOf( + Tasks.LIST_ID to "INTEGER", + Tasks.LIST_NAME to "TEXT", + Tasks.LIST_COLOR to "INTEGER", + Tasks.LIST_OWNER to "TEXT", + Tasks.LIST_ACCESS_LEVEL to "INTEGER", + Tasks.VISIBLE to "INTEGER", + Tasks.ACCOUNT_NAME to "TEXT", + Tasks.ACCOUNT_TYPE to "TEXT", + Tasks.UID to "TEXT", + Tasks.DELETED to "INTEGER", + Tasks.TITLE to "TEXT", + Tasks.DESCRIPTION to "TEXT", + Tasks.LOCATION to "TEXT", + Tasks.URL to "TEXT", + Tasks.PRIORITY to "INTEGER", + Tasks.CLASSIFICATION to "INTEGER", + Tasks.STATUS to "INTEGER", + Tasks.PERCENT_COMPLETE to "INTEGER", + Tasks.COMPLETED to "INTEGER", + Tasks.IS_CLOSED to "INTEGER", + Tasks.IS_NEW to "INTEGER", + Tasks.TASK_COLOR to "INTEGER", + Tasks.DTSTART to "INTEGER", + Tasks.DUE to "INTEGER", + Tasks.DURATION to "TEXT", + Tasks.IS_ALLDAY to "INTEGER", + Tasks.TZ to "TEXT", + Tasks.RRULE to "TEXT", + Tasks.RDATE to "TEXT", + Tasks.EXDATE to "TEXT", + Tasks.ORIGINAL_INSTANCE_ID to "INTEGER", + Tasks.ORIGINAL_INSTANCE_TIME to "INTEGER", + Tasks.ORIGINAL_INSTANCE_ALLDAY to "INTEGER", + Tasks.PARENT_ID to "INTEGER", + Tasks.SORTING to "INTEGER", + Tasks.HAS_ALARMS to "INTEGER", + Tasks.CREATED to "INTEGER", + Tasks.LAST_MODIFIED to "INTEGER", + ) + UNSUPPORTED_TASK_COLUMNS.map { it to "TEXT" } + +/** + * OpenTasks columns Agendula has nothing for. Published as `NULL` so a client + * that projects them reads nothing rather than failing the query. + */ +private val UNSUPPORTED_TASK_COLUMNS = listOf( + "geo", "organizer", "pinned", "has_properties", "completed_is_allday", "original_instance_sync_id", + "version", "_dirty", "_sync_id", "sync_version", + "sync1", "sync2", "sync3", "sync4", "sync5", "sync6", "sync7", "sync8", +) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderSnapshot.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderSnapshot.kt new file mode 100644 index 0000000..ae58715 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderSnapshot.kt @@ -0,0 +1,267 @@ +package de.jeanlucmakiola.agendula.data.tasks.provider + +import de.jeanlucmakiola.agendula.data.tasks.TasksContract +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Alarm +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Category +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks +import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference +import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity +import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity +import de.jeanlucmakiola.agendula.data.tasks.room.TaskListRow +import de.jeanlucmakiola.agendula.data.tasks.room.seriesOccurrences +import de.jeanlucmakiola.agendula.domain.TaskStatus +import de.jeanlucmakiola.agendula.domain.toInt +import java.time.ZoneId +import kotlin.time.Instant + +typealias ProviderRow = Map + +/** + * Ids that stay put for as long as what they name exists, so a client can read + * an id and write to it later, or several in one batch. The one exception is + * two tags on one task whose spellings hash alike (about one in 2³¹): removing + * the one that sorts first moves the other onto its id. + * + * An instance is its series and its original time; a plain task's one + * instance has none. An alarm property is the alarm row; a tag property is its + * task and its spelling. Alarms take even ids and tags odd ones, so the two + * never meet. + */ +object ProviderIds { + + fun instance(seriesId: Long, originalTime: Instant?): Long = + (seriesId shl 32) or ((originalTime?.epochSeconds ?: 0L) and 0xFFFFFFFFL) + + fun instanceSeries(instanceId: Long): Long = instanceId ushr 32 + + fun instanceSeconds(instanceId: Long): Long = instanceId and 0xFFFFFFFFL + + fun alarm(alarmId: Long): Long = alarmId shl 1 + + fun isAlarm(propertyId: Long): Boolean = propertyId and 1L == 0L + + fun alarmRow(propertyId: Long): Long = propertyId ushr 1 + + /** The property id of each of [taskId]'s [categories], by spelling. */ + fun categories(taskId: Long, categories: List): Map = + categories(taskId, categories, ::tagHash) + + internal fun categories(taskId: Long, categories: List, hash: (String) -> Long): Map { + val used = HashSet() + // Two spellings on one task with one hash: the one that sorts later takes + // the next free slot, wrapping inside the tag bits so it never reaches the + // task's. Sorting keeps that independent of the tags' order. + val ids = categories.sorted().associateWith { name -> + var bits = hash(name) and TAG_BITS + while (!used.add(bits)) bits = (bits + 1) and TAG_BITS + (((taskId shl 31) or bits) shl 1) or 1L + } + return categories.associateWith(ids::getValue) + } + + fun categoryTask(propertyId: Long): Long = propertyId ushr 32 + + private const val TAG_BITS = 0x7FFFFFFFL + + /** 64-bit FNV-1a over the spelling's UTF-8, folded to 31 bits. */ + private fun tagHash(name: String): Long { + var hash = -0x340d631b7bdddcdbL + name.encodeToByteArray().forEach { byte -> + hash = (hash xor (byte.toLong() and 0xFF)) * 0x100000001b3L + } + return (hash xor (hash ushr 31) xor (hash ushr 62)) and TAG_BITS + } +} + +/** The Room store laid out as OpenTasks' tables, ready to be queried. */ +class ProviderSnapshot(val rows: Map>) { + + companion object { + val EMPTY = ProviderSnapshot(ProviderTable.entries.associateWith { emptyList() }) + + /** + * Lays out the store. [tasks] must hold no tombstones. [accountType] is + * what a list attached to an account reports; a device-only list reports + * OpenTasks' local account so clients treat it the way they already do. + */ + fun build( + lists: List, + tasks: List, + alarms: List, + accountType: String, + now: Instant, + zone: ZoneId = ZoneId.systemDefault(), + ): ProviderSnapshot { + val listsById = lists.associateBy { it.list.id } + val alarmsByTask = alarms.groupBy { it.taskId } + val taskRows = tasks.mapNotNull { task -> + val list = listsById[task.listId] ?: return@mapNotNull null + task.id to taskColumns(task, list, alarmsByTask[task.id].orEmpty().isNotEmpty(), accountType) + }.toMap() + + val overrides = tasks.filter { it.masterId != null }.groupBy { it.masterId } + val instances = tasks.filter { it.masterId == null && it.id in taskRows }.flatMap { task -> + occurrences(task, overrides[task.id].orEmpty(), now).mapNotNull { occurrence -> + val columns = taskRows[occurrence.row.id] ?: return@mapNotNull null + columns + instanceColumns(ProviderIds.instance(task.id, occurrence.originalTime), occurrence, zone) + } + } + + return ProviderSnapshot( + mapOf( + ProviderTable.LISTS to lists.map { listColumns(it, accountType) }, + ProviderTable.TASKS to taskRows.map { (id, columns) -> columns + (Tasks.ID to id) }, + ProviderTable.INSTANCES to instances, + ProviderTable.PROPERTIES to properties(tasks.filter { it.id in taskRows }, alarms), + ), + ) + } + + private fun listColumns(row: TaskListRow, accountType: String): ProviderRow = mapOf( + Lists.ID to row.list.id, + Lists.NAME to row.list.name, + Lists.COLOR to row.list.color, + Lists.ACCESS_LEVEL to 0, + Lists.VISIBLE to row.list.isVisible.bit, + Lists.SYNC_ENABLED to row.list.isSynced.bit, + Lists.OWNER to row.list.owner, + Lists.ACCOUNT_NAME to accountName(row), + Lists.ACCOUNT_TYPE to accountTypeOf(row, accountType), + ) + + private fun taskColumns(task: TaskEntity, list: TaskListRow, hasAlarms: Boolean, accountType: String) = + mapOf( + Tasks.LIST_ID to task.listId, + Tasks.LIST_NAME to list.list.name, + Tasks.LIST_COLOR to list.list.color, + Tasks.LIST_OWNER to list.list.owner, + Tasks.LIST_ACCESS_LEVEL to 0, + Tasks.VISIBLE to list.list.isVisible.bit, + Tasks.ACCOUNT_NAME to accountName(list), + Tasks.ACCOUNT_TYPE to accountTypeOf(list, accountType), + Tasks.UID to task.uid, + Tasks.DELETED to 0, + Tasks.TITLE to task.title, + Tasks.DESCRIPTION to task.description, + Tasks.LOCATION to task.location, + Tasks.URL to task.url, + Tasks.PRIORITY to task.priority, + Tasks.CLASSIFICATION to task.classification, + Tasks.STATUS to task.status.toInt(), + Tasks.PERCENT_COMPLETE to task.percentComplete, + Tasks.COMPLETED to task.completedAt?.toEpochMilliseconds(), + Tasks.IS_CLOSED to (task.status == TaskStatus.COMPLETED || task.status == TaskStatus.CANCELLED).bit, + Tasks.IS_NEW to (task.status == TaskStatus.NEEDS_ACTION && task.percentComplete == null).bit, + Tasks.TASK_COLOR to task.color, + Tasks.DTSTART to task.dtstart?.toEpochMilliseconds(), + Tasks.DUE to task.due?.toEpochMilliseconds(), + Tasks.DURATION to task.duration, + Tasks.IS_ALLDAY to task.isAllDay.bit, + Tasks.TZ to task.timezone, + Tasks.RRULE to task.rrule, + Tasks.RDATE to task.rdate, + Tasks.EXDATE to task.exdate, + Tasks.ORIGINAL_INSTANCE_ID to task.masterId, + Tasks.ORIGINAL_INSTANCE_TIME to task.recurrenceId?.toEpochMilliseconds(), + Tasks.ORIGINAL_INSTANCE_ALLDAY to task.masterId?.let { task.isAllDay.bit }, + Tasks.PARENT_ID to task.parentId, + Tasks.SORTING to task.sortOrder, + Tasks.HAS_ALARMS to hasAlarms.bit, + Tasks.CREATED to task.createdAt?.toEpochMilliseconds(), + Tasks.LAST_MODIFIED to task.lastModified?.toEpochMilliseconds(), + ) + + /** One instance: the row it reads from, its times, and where it sits in its series. */ + private class Occurrence( + val row: TaskEntity, + val start: Instant?, + val due: Instant?, + val originalTime: Instant?, + val distance: Int, + val recurring: Boolean, + ) + + private fun occurrences(master: TaskEntity, overrides: List, now: Instant): List { + val series = seriesOccurrences(master, overrides, now) + ?: return listOf(Occurrence(master, master.dtstart, master.due, null, 0, recurring = false)) + return series.mapIndexed { index, occurrence -> + Occurrence( + row = occurrence.override ?: master, + start = occurrence.start, + due = occurrence.due, + originalTime = occurrence.anchor, + distance = occurrence.distance ?: index, + recurring = true, + ) + } + } + + private fun instanceColumns(id: Long, occurrence: Occurrence, zone: ZoneId): ProviderRow { + val allDay = occurrence.row.isAllDay + val start = occurrence.start?.toEpochMilliseconds() + val due = occurrence.due?.toEpochMilliseconds() + return mapOf( + Tasks.ID to id, + Instances.TASK_ID to occurrence.row.id, + Instances.INSTANCE_START to start, + Instances.INSTANCE_START_SORTING to start?.let { sorting(it, allDay, zone) }, + Instances.INSTANCE_DUE to due, + Instances.INSTANCE_DUE_SORTING to due?.let { sorting(it, allDay, zone) }, + Instances.INSTANCE_DURATION to if (start != null && due != null) due - start else null, + Instances.INSTANCE_ORIGINAL_TIME to occurrence.originalTime?.toEpochMilliseconds(), + Instances.DISTANCE_FROM_CURRENT to occurrence.distance, + Instances.IS_RECURRING to occurrence.recurring.bit, + ) + } + + /** + * An all-day date is stored as UTC midnight; OpenTasks' sorting columns + * move it to local midnight so it sorts among the timed tasks of that day. + */ + private fun sorting(millis: Long, allDay: Boolean, zone: ZoneId): Long { + if (!allDay) return millis + val offset = zone.rules.getOffset(java.time.Instant.ofEpochMilli(millis)).totalSeconds * 1000L + return millis - offset + } + + private fun properties(tasks: List, alarms: List): List { + val live = tasks.mapTo(HashSet()) { it.id } + val alarmRows = alarms.filter { it.taskId in live }.map { + mapOf( + Properties.PROPERTY_ID to ProviderIds.alarm(it.id), + Properties.TASK_ID to it.taskId, + Properties.MIMETYPE to Alarm.MIMETYPE, + Alarm.MINUTES_BEFORE to it.minutesBefore.toString(), + Alarm.REFERENCE to when (it.reference) { + AlarmReference.DUE -> Alarm.REFERENCE_DUE + AlarmReference.START -> Alarm.REFERENCE_START + }.toString(), + Alarm.MESSAGE to it.message, + Alarm.ALARM_TYPE to Alarm.TYPE_MESSAGE.toString(), + ) + } + val categoryRows = tasks.flatMap { task -> + ProviderIds.categories(task.id, task.categories).map { (name, id) -> + mapOf( + Properties.PROPERTY_ID to id, + Properties.TASK_ID to task.id, + Properties.MIMETYPE to Category.MIMETYPE, + Category.NAME to name, + ) + } + } + return alarmRows + categoryRows + } + + private fun accountName(row: TaskListRow): String = + row.accountDisplayName ?: TasksContract.LOCAL_ACCOUNT_NAME + + private fun accountTypeOf(row: TaskListRow, accountType: String): String = + if (row.list.accountId == null) TasksContract.LOCAL_ACCOUNT_TYPE else accountType + + private val Boolean.bit: Int get() = if (this) 1 else 0 + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderSnapshotSource.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderSnapshotSource.kt new file mode 100644 index 0000000..d69ce02 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderSnapshotSource.kt @@ -0,0 +1,182 @@ +package de.jeanlucmakiola.agendula.data.tasks.provider + +import android.content.Context +import android.database.Cursor +import android.os.Handler +import android.os.Looper +import androidx.room.InvalidationTracker +import dagger.hilt.android.qualifiers.ApplicationContext +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver +import de.jeanlucmakiola.agendula.data.tasks.StartupGate +import de.jeanlucmakiola.agendula.data.tasks.StorageMode +import de.jeanlucmakiola.agendula.data.tasks.TasksContract +import de.jeanlucmakiola.agendula.data.tasks.room.OBSERVED_TABLES +import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity +import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity +import de.jeanlucmakiola.agendula.data.tasks.room.TaskListRow +import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant + +/** Rebuilt at least this often, so the expansion window and `distance_from_current` follow the clock. */ +private val MAX_AGE = 1.hours + +private val STARTUP_WAIT = 10.seconds + +private val NOTIFY_DELAY = 500.milliseconds + +/** + * Serves [AgendulaTaskProvider]: keeps a [SnapshotDatabase] of the Room store, + * rebuilt lazily after a write or a storage-mode switch, and tells observers of + * the provider's authority when either happens. + * + * Empty in [StorageMode.EXTERNAL]: the Room store is not the user's data then, + * and clients should read OpenTasks or tasks.org directly. + */ +@Singleton +class ProviderSnapshotSource internal constructor( + private val context: Context, + private val database: TasksDatabase, + private val resolver: ProviderResolver, + private val awaitStartup: suspend () -> Unit, + private val clock: Clock = Clock.System, + private val startupWait: Duration = STARTUP_WAIT, +) { + + @Inject constructor( + @ApplicationContext context: Context, + database: TasksDatabase, + resolver: ProviderResolver, + startupGate: StartupGate, + ) : this(context, database, resolver, startupGate::awaitReady) + + private val started = AtomicBoolean(false) + private val snapshot by lazy { SnapshotDatabase() } + private val generation = AtomicLong(1) + private var builtGeneration = 0L + private var builtAt: Instant? = null + + private val handler = Handler(Looper.getMainLooper()) + private val notify = Runnable { + context.contentResolver.notifyChange(TasksContract.authorityUri(authority), null) + } + + val authority: String get() = "${context.packageName}.$AUTHORITY_SUFFIX" + + /** Starts watching for changes. Idempotent; touches the database, so not from the main thread. */ + fun start() { + if (!started.compareAndSet(false, true)) return + database.invalidationTracker.addObserver( + object : InvalidationTracker.Observer(OBSERVED_TABLES) { + override fun onInvalidated(tables: Set) { + if (resolver.mode() == StorageMode.OWN) changed() + } + }, + ) + resolver.onModeChanged(::changed) + } + + @Synchronized + fun query( + table: ProviderTable, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String?, + ): Cursor { + refresh() + return snapshot.query(table, projection, selection, selectionArgs, sortOrder) + } + + /** + * Runs [block] holding the snapshot's lock, once startup has settled which + * store is the user's. A write refuses to run without that, where a read + * falls back to answering from whichever store autoMode() picks. + */ + fun locked(block: () -> T): T { + start() + checkNotNull(runBlocking { withTimeoutOrNull(startupWait) { awaitStartup() } }) { + "Agendula is still starting; try again" + } + return synchronized(this, block) + } + + /** The [columns] of every row of [table] that [selection] matches, as of now. */ + @Synchronized + fun rows(table: ProviderTable, columns: List, selection: String?, selectionArgs: Array?): List> = + query(table, columns.toTypedArray(), selection, selectionArgs, null).use { cursor -> + buildList { + while (cursor.moveToNext()) { + add( + List(cursor.columnCount) { column -> + when (cursor.getType(column)) { + Cursor.FIELD_TYPE_NULL -> null + Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(column) + else -> cursor.getString(column) + } + }, + ) + } + } + } + + /** + * Marks the snapshot stale at once. A provider write calls this so the next + * operation of the same batch sees it, before Room's own invalidation lands. + */ + fun invalidate() { + generation.incrementAndGet() + } + + @Synchronized + private fun refresh() { + start() + runBlocking { withTimeoutOrNull(startupWait) { awaitStartup() } } + val now = clock.now() + val current = generation.get() + val expired = builtAt.let { it == null || now - it > MAX_AGE } + if (current != builtGeneration || expired) { + snapshot.load(build(now)) + builtGeneration = current + // Instance ids and distances move with the clock, so a timed rebuild is a change too. + if (expired && builtAt != null) notifyObservers() + builtAt = now + } + } + + private fun build(now: Instant): ProviderSnapshot { + if (resolver.mode() != StorageMode.OWN) return ProviderSnapshot.EMPTY + // Read consistently in one transaction; expand outside it, so the app's + // own writes do not wait on recurrence expansion. + val (lists, tasks, alarms) = database.runInTransaction, List, List>> { + Triple(database.taskLists().lists(), database.tasks().live(), database.alarms().all()) + } + return ProviderSnapshot.build(lists, tasks, alarms, context.getString(R.string.account_type), now) + } + + private fun changed() { + generation.incrementAndGet() + notifyObservers() + } + + /** Coalesced, so a sync committing many small transactions wakes each client once. */ + private fun notifyObservers() { + handler.removeCallbacks(notify) + handler.postDelayed(notify, NOTIFY_DELAY.inWholeMilliseconds) + } + + companion object { + const val AUTHORITY_SUFFIX = "tasks" + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderTaskWriter.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderTaskWriter.kt new file mode 100644 index 0000000..0fa71c6 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderTaskWriter.kt @@ -0,0 +1,304 @@ +package de.jeanlucmakiola.agendula.data.tasks.provider + +import de.jeanlucmakiola.agendula.data.tasks.TasksContract +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks +import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity +import de.jeanlucmakiola.agendula.data.tasks.room.forAllDay +import de.jeanlucmakiola.agendula.domain.TaskStatus +import de.jeanlucmakiola.agendula.domain.statusFromInt +import org.dmfs.rfc5545.recur.RecurrenceRule +import java.time.ZoneId +import kotlin.time.Instant + +/** A client's `ContentValues`, as plain Kotlin values. */ +typealias ProviderValues = Map + +/** The `tasks` columns a client may write. Everything else the table publishes is ignored on a write. */ +val WRITABLE_TASK_COLUMNS: Set = setOf( + Tasks.LIST_ID, Tasks.UID, Tasks.TITLE, Tasks.DESCRIPTION, Tasks.LOCATION, Tasks.URL, Tasks.PRIORITY, + Tasks.CLASSIFICATION, Tasks.STATUS, Tasks.PERCENT_COMPLETE, Tasks.COMPLETED, Tasks.TASK_COLOR, Tasks.DTSTART, + Tasks.DUE, Tasks.DURATION, Tasks.IS_ALLDAY, Tasks.TZ, Tasks.RRULE, Tasks.RDATE, Tasks.EXDATE, Tasks.PARENT_ID, + Tasks.SORTING, +) + +/** The columns that say whether a task, or one occurrence of it, is done. */ +val COMPLETION_COLUMNS: Set = setOf(Tasks.STATUS, Tasks.PERCENT_COMPLETE, Tasks.COMPLETED) + +/** The columns that place a task in time; one occurrence of a series cannot be moved through the provider. */ +val TIME_COLUMNS: Set = setOf( + Tasks.DTSTART, Tasks.DUE, Tasks.DURATION, Tasks.IS_ALLDAY, Tasks.TZ, Tasks.RRULE, Tasks.RDATE, Tasks.EXDATE, +) + +/** What a write to one occurrence of a series does to it. */ +enum class OccurrenceChange { COMPLETE, CANCEL, REOPEN } + +private val ICAL_DATE = Regex("""\d{8}(T\d{6}Z?)?""") + +private val ICAL_DURATION = Regex("""[+-]?P(\d+W|(\d+D)?(T(\d+H)?(\d+M)?(\d+S)?)?)""") + +/** + * Turns a client's OpenTasks `ContentValues` into [TaskEntity] changes. Pure, so + * the rules are testable on the JVM. A bad value throws [IllegalArgumentException], + * which reaches the client as is. + * + * Only [WRITABLE_TASK_COLUMNS] change anything. The rest of what the provider + * publishes is accepted and ignored, so a client can write back a row it read; + * the fields Agendula keeps for CalDAV (`href`, `etag`, `sequence`, unknown + * properties) are not published and so cannot be written at all. + */ +object ProviderTaskWriter { + + /** + * [values] narrowed to the columns in [writable]. A column [table] does not + * have at all is a client bug and throws. + */ + fun writable(table: ProviderTable, values: ProviderValues, writable: Set): ProviderValues { + val known = table.columns.mapTo(HashSet()) { it.first } + val unknown = values.keys - known + require(unknown.isEmpty()) { "Unknown column(s) for ${table.path}: ${unknown.sorted().joinToString()}" } + return values.filterKeys { it in writable } + } + + /** A new task from [values], which must name its list. */ + fun newTask(values: ProviderValues, uid: String, now: Instant, tzId: String): TaskEntity { + val v = Values(values) + val listId = requireNotNull(v.long(Tasks.LIST_ID)) { "A new task needs a ${Tasks.LIST_ID}" } + val base = TaskEntity(listId = listId, uid = v.string(Tasks.UID)?.trim()?.ifEmpty { null } ?: uid, createdAt = now) + // As in OpenTasks, a new task inserted without a zone is floating. + val zoned = if (Tasks.TZ in v) values else values + (Tasks.TZ to null) + return applied(base, zoned - Tasks.LIST_ID - Tasks.UID, now, tzId) + } + + /** + * [current] with [values] applied. Identity and sync columns are left alone, + * and [current] itself comes back when nothing changed, so a write that + * changes nothing owes the server nothing. + */ + fun patch(current: TaskEntity, values: ProviderValues, now: Instant, tzId: String): TaskEntity { + val edited = applied(current, values, now, tzId) + return if (edited.copy(lastModified = current.lastModified, isDirty = current.isDirty) == current) current else edited + } + + /** + * The columns in [values] that would change [current] on their own. Writing + * back what a row published changes nothing, so a client that read a row can + * send all of it with the one value it meant to change. + */ + fun changedColumns(current: TaskEntity, values: ProviderValues, tzId: String): Set { + val stamp = current.lastModified ?: Instant.DISTANT_PAST + return values.keys.filterTo(HashSet()) { key -> + val edited = runCatching { applied(current, mapOf(key to values[key]), stamp, tzId) }.getOrNull() + edited == null || edited.copy(lastModified = current.lastModified, isDirty = current.isDirty) != current + } + } + + private fun applied(current: TaskEntity, values: ProviderValues, now: Instant, tzId: String): TaskEntity { + val v = Values(values) + val isOverride = current.masterId != null + if (Tasks.UID in v) require(v.string(Tasks.UID)?.trim() == current.uid) { "A task's ${Tasks.UID} cannot change" } + if (isOverride) { + // NULL is what the row publishes, so writing it back is fine. + require(listOf(Tasks.RRULE, Tasks.RDATE, Tasks.EXDATE).none { it in v && v.text(it) != null }) { + "A modified occurrence carries no recurrence rule of its own" + } + if (Tasks.LIST_ID in v) require(v.long(Tasks.LIST_ID) == current.listId) { "A modified occurrence stays in its series' list" } + if (Tasks.PARENT_ID in v) require(v.long(Tasks.PARENT_ID) == current.parentId) { "A modified occurrence keeps its series' parent" } + } + + val listId = if (Tasks.LIST_ID in v) requireNotNull(v.long(Tasks.LIST_ID)) { "${Tasks.LIST_ID} cannot be null" } else current.listId + val parentId = if (Tasks.PARENT_ID in v) v.long(Tasks.PARENT_ID)?.takeIf { it > 0 } else current.parentId + require(parentId == null || parentId != current.id || current.id == 0L) { "A task cannot be its own parent" } + + val allDay = if (Tasks.IS_ALLDAY in v) v.boolean(Tasks.IS_ALLDAY) else current.isAllDay + val dtstart = (if (Tasks.DTSTART in v) v.instant(Tasks.DTSTART) else current.dtstart)?.forAllDay(allDay) + val due = (if (Tasks.DUE in v) v.instant(Tasks.DUE) else current.due)?.forAllDay(allDay) + val duration = when { + Tasks.DURATION in v -> v.string(Tasks.DURATION)?.trim()?.ifEmpty { null } + ?.also { require(ICAL_DURATION.matches(it)) { "Not an iCalendar duration: $it" } } + Tasks.DUE in v && due != null -> null + else -> current.duration + } + require(due == null || duration == null) { "${Tasks.DUE} and ${Tasks.DURATION} are mutually exclusive" } + require(dtstart == null || due == null || due >= dtstart) { "${Tasks.DUE} is before ${Tasks.DTSTART}" } + val timezone = when { + allDay -> null + Tasks.TZ in v -> v.string(Tasks.TZ)?.also { zone -> + require(runCatching { ZoneId.of(zone) }.isSuccess) { "Unknown time zone: $zone" } + } + dtstart == null && due == null -> null + // A timed task without a zone is floating (RFC 5545 §3.3.5) and stays so. + current.dtstart != null || current.due != null -> if (current.isAllDay) tzId else current.timezone + else -> current.timezone ?: tzId + } + + val rrule = if (Tasks.RRULE in v) normalizedRule(v.string(Tasks.RRULE)) else current.rrule + val ruleDropped = Tasks.RRULE in v && rrule == null && current.rrule != null + val rdate = if (Tasks.RDATE in v) dateList(Tasks.RDATE, v, current.rdate) else current.rdate.takeUnless { ruleDropped } + val exdate = if (Tasks.EXDATE in v) dateList(Tasks.EXDATE, v, current.exdate) else current.exdate.takeUnless { ruleDropped } + require(rrule == null || dtstart != null || due != null) { "A recurring task needs a ${Tasks.DTSTART} or ${Tasks.DUE}" } + + val (status, percent, completedAt) = completion(current, v, now) + + return current.copy( + listId = listId, + title = if (Tasks.TITLE in v) v.string(Tasks.TITLE)?.trim() else current.title, + description = if (Tasks.DESCRIPTION in v) v.text(Tasks.DESCRIPTION) else current.description, + location = if (Tasks.LOCATION in v) v.text(Tasks.LOCATION) else current.location, + url = if (Tasks.URL in v) v.text(Tasks.URL) else current.url, + priority = if (Tasks.PRIORITY in v) v.int(Tasks.PRIORITY, 0..9) ?: TasksContract.PRIORITY_NONE else current.priority, + classification = if (Tasks.CLASSIFICATION in v) v.int(Tasks.CLASSIFICATION, 0..2) else current.classification, + color = if (Tasks.TASK_COLOR in v) v.int(Tasks.TASK_COLOR) else current.color, + sortOrder = if (Tasks.SORTING in v) v.int(Tasks.SORTING) ?: 0 else current.sortOrder, + status = status, + percentComplete = percent, + completedAt = completedAt, + dtstart = dtstart, + due = due, + duration = duration, + isAllDay = allDay, + timezone = timezone, + rrule = rrule, + rdate = rdate, + exdate = exdate, + parentId = parentId, + lastModified = now, + isDirty = true, + ) + } + + /** + * What [values] do to one occurrence of a series, or `null` when they say + * nothing about its completion. An occurrence is done, cancelled or open; + * partial progress belongs to a task, not to one of its occurrences. + */ + fun occurrenceChange(values: ProviderValues): OccurrenceChange? { + val v = Values(values.filterKeys { it in COMPLETION_COLUMNS }) + return when { + Tasks.STATUS in v -> when (v.int(Tasks.STATUS, 0..3)) { + TasksContract.STATUS_COMPLETED -> OccurrenceChange.COMPLETE + TasksContract.STATUS_CANCELLED -> OccurrenceChange.CANCEL + TasksContract.STATUS_NEEDS_ACTION, null -> OccurrenceChange.REOPEN + else -> throw IllegalArgumentException("An occurrence is completed, cancelled or open, not in process") + } + Tasks.PERCENT_COMPLETE in v -> when (v.int(Tasks.PERCENT_COMPLETE, 0..100)) { + 100 -> OccurrenceChange.COMPLETE + 0, null -> OccurrenceChange.REOPEN + else -> throw IllegalArgumentException("An occurrence is completed or open; partial progress belongs to its task") + } + Tasks.COMPLETED in v -> if (v.instant(Tasks.COMPLETED) != null) OccurrenceChange.COMPLETE else OccurrenceChange.REOPEN + else -> null + } + } + + /** + * Status, progress and completion time after a write. They move together the + * way OpenTasks keeps them: a status implies its progress and time, progress + * implies a status, and a completion time alone completes the task. When a + * row is written back, the column that changed decides, not the ones it + * carries unchanged. + */ + private fun completion(current: TaskEntity, values: Values, now: Instant): Triple { + val v = Values(values.map.filterKeys { it !in COMPLETION_COLUMNS || it in completionChanged(current, values) }) + val percent = if (Tasks.PERCENT_COMPLETE in v) v.int(Tasks.PERCENT_COMPLETE, 0..100) else current.percentComplete + val completed = if (Tasks.COMPLETED in v) v.instant(Tasks.COMPLETED) else current.completedAt + return when { + Tasks.STATUS in v -> when (val status = statusFromInt(v.int(Tasks.STATUS, 0..3))) { + TaskStatus.COMPLETED -> Triple(status, if (Tasks.PERCENT_COMPLETE in v) percent else 100, completed ?: now) + TaskStatus.NEEDS_ACTION -> + Triple(status, if (Tasks.PERCENT_COMPLETE in v) percent?.takeIf { it < 100 } else null, null) + else -> Triple(status, percent, null) + } + Tasks.PERCENT_COMPLETE in v -> when { + percent == null -> Triple(current.status, null, current.completedAt) + percent >= 100 -> Triple(TaskStatus.COMPLETED, percent, completed ?: now) + percent > 0 -> Triple(TaskStatus.IN_PROCESS, percent, null) + else -> Triple(TaskStatus.NEEDS_ACTION, percent, null) + } + Tasks.COMPLETED in v -> when { + completed != null -> Triple(TaskStatus.COMPLETED, 100, completed) + // Clearing the time reopens a completed task; an open one has none to clear. + current.status == TaskStatus.COMPLETED -> Triple(TaskStatus.NEEDS_ACTION, null, null) + else -> Triple(current.status, current.percentComplete, null) + } + else -> Triple(current.status, current.percentComplete, current.completedAt) + } + } + + /** + * The completion columns in [v] that differ from [current]. All of them when + * none does, so a write that only repeats the row still settles the rest. + */ + private fun completionChanged(current: TaskEntity, v: Values): Set { + val present = COMPLETION_COLUMNS.filterTo(HashSet()) { it in v } + val changed = present.filterTo(HashSet()) { column -> + when (column) { + Tasks.STATUS -> statusFromInt(v.int(Tasks.STATUS, 0..3)) != current.status + Tasks.PERCENT_COMPLETE -> v.int(Tasks.PERCENT_COMPLETE, 0..100) != current.percentComplete + else -> v.instant(Tasks.COMPLETED) != current.completedAt + } + } + return changed.ifEmpty { present } + } + + /** + * An `RDATE`/`EXDATE` value: DATE or DATE-TIME values, comma-separated, of + * one kind. [stored] written back as is stays, whatever shape it synced in. + */ + private fun dateList(column: String, v: Values, stored: String?): String? { + val raw = v.string(column)?.trim()?.ifEmpty { null } ?: return null + if (raw == stored?.trim()) return stored + val values = raw.split(',').map(String::trim) + require(values.all { ICAL_DATE.matches(it) }) { "$column takes iCalendar dates or date-times: $raw" } + // lib-recur reads the whole list or none of it, so it has to be one kind. + val kinds = values.map { if (it.length == 8) "date" else if (it.endsWith('Z')) "utc" else "local" } + require(kinds.distinct().size == 1) { "$column mixes value kinds: $raw" } + return values.joinToString(",") + } + + private fun normalizedRule(raw: String?): String? { + val rule = raw?.trim()?.removePrefix("RRULE:")?.ifEmpty { null } ?: return null + require(runCatching { RecurrenceRule(rule) }.isSuccess) { "Not a recurrence rule: $rule" } + return rule + } +} + +/** Lenient reads over [values]: a client may hand a number as a string or a flag as an integer. */ +internal class Values(private val values: ProviderValues) { + + val keys: Set get() = values.keys + + val map: ProviderValues get() = values + + operator fun contains(key: String) = key in values + + fun string(key: String): String? = values[key]?.toString() + + /** A free-text column: trimmed, blank stored as nothing. */ + fun text(key: String): String? = string(key)?.trim()?.ifEmpty { null } + + fun long(key: String): Long? = when (val value = values[key]) { + null -> null + is Number -> value.toLong() + is Boolean -> if (value) 1L else 0L + is String -> requireNotNull(value.trim().toLongOrNull()) { "$key is not a number: $value" } + else -> throw IllegalArgumentException("$key is not a number: $value") + } + + fun int(key: String, range: IntRange? = null): Int? = long(key)?.let { value -> + require(value in Int.MIN_VALUE..Int.MAX_VALUE) { "$key is out of range: $value" } + value.toInt().also { require(range == null || it in range) { "$key must be in $range: $it" } } + } + + fun boolean(key: String): Boolean = when (val value = values[key]) { + null -> false + is Boolean -> value + is String -> when (value.trim().lowercase()) { + "1", "true" -> true + "0", "false", "" -> false + else -> throw IllegalArgumentException("$key is not a flag: $value") + } + else -> long(key) != 0L + } + + fun instant(key: String): Instant? = long(key)?.let(Instant::fromEpochMilliseconds) +} 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 new file mode 100644 index 0000000..7b1036f --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderWriter.kt @@ -0,0 +1,441 @@ +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.tasks.ProviderResolver +import de.jeanlucmakiola.agendula.data.tasks.StorageMode +import de.jeanlucmakiola.agendula.data.tasks.TaskWriteFailedException +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Alarm +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Category +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks +import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference +import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource +import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity +import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity +import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase +import 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 +import javax.inject.Singleton +import kotlin.time.Clock +import kotlin.time.Instant + +/** One row by id, ` = N` or ` = ?`. */ +private val SINGLE_ROW = Regex("""\s*(\w+)\s*=\s*(\d+|\?)\s*""") + +/** + * Applies another app's writes to the own store for [AgendulaTaskProvider]. + * + * A write's selection runs against the published snapshot, so it matches the + * rows the client can read, and each matched row is then written through + * [RoomTasksDataSource] under the rules the app's own edits follow. `tasklists` + * stays read-only. On `instances`, completion columns tick off that one + * occurrence of a series; other content columns edit the series, and a changed + * time column is refused, since one occurrence cannot be moved this way. + * + * Each call runs in one transaction, and a batch in one more around them. + */ +@Singleton +class ProviderWriter internal constructor( + private val database: TasksDatabase, + private val store: RoomTasksDataSource, + private val source: ProviderSnapshotSource, + private val resolver: ProviderResolver, + private val afterWrite: () -> Unit, + private val clock: Clock = Clock.System, +) { + + @Inject constructor( + database: TasksDatabase, + store: RoomTasksDataSource, + source: ProviderSnapshotSource, + resolver: ProviderResolver, + scheduler: ReminderScheduler, + @ApplicationScope scope: CoroutineScope, + ) : this(database, store, source, resolver, { scope.launch { runCatching { scheduler.sync() } } }) + + private var batchDepth = 0 + private var batchWrote = false + + /** Inserts one row and returns its published id. */ + fun insert(table: ProviderTable, values: ProviderValues): Long = write { + when (table) { + ProviderTable.TASKS -> insertTask(values) + ProviderTable.PROPERTIES -> insertProperty(values) + else -> throw UnsupportedOperationException("${table.path} is read-only") + } + } + + /** Updates every row [selection] matches and returns how many changed. */ + fun update(table: ProviderTable, values: ProviderValues, selection: String?, args: Array?): Int = write { + when (table) { + ProviderTable.TASKS -> { + val writable = ProviderTaskWriter.writable(table, values, WRITABLE_TASK_COLUMNS) + val matched = ids(table, selection, args) + (if (Tasks.LIST_ID in writable) parentsFirst(matched) else matched).count { updateTask(it, writable) } + } + ProviderTable.INSTANCES -> { + val writable = ProviderTaskWriter.writable(table, values, WRITABLE_TASK_COLUMNS) + instances(selection, args).count { updateInstance(it, writable) } + } + ProviderTable.PROPERTIES -> ids(table, selection, args).count { updateProperty(it, values) } + else -> throw UnsupportedOperationException("${table.path} is read-only") + } + } + + /** Deletes every row [selection] matches and returns how many went. */ + fun delete(table: ProviderTable, selection: String?, args: Array?): Int = write { + when (table) { + ProviderTable.TASKS -> ids(table, selection, args).count { deleteTask(it) } + ProviderTable.INSTANCES -> instances(selection, args).count { deleteInstance(it) } + ProviderTable.PROPERTIES -> ids(table, selection, args).count { deleteProperty(it) } + else -> throw UnsupportedOperationException("${table.path} is read-only") + } + } + + /** Runs a client's batch in one transaction, re-arming reminders once at the end. */ + fun batch(block: () -> T): T = source.locked { + batchDepth++ + try { + database.runInTransaction { block() } + } finally { + // A snapshot built mid-batch may hold rows a failure rolled back. + source.invalidate() + batchDepth-- + if (batchDepth == 0 && batchWrote) { + batchWrote = false + afterWrite() + } + } + } + + private fun write(block: () -> T): T = source.locked { + check(resolver.mode() == StorageMode.OWN) { + "Agendula is working on top of an external tasks provider; write to that one instead" + } + val result = try { + database.runInTransaction { block() } + } catch (e: TaskWriteFailedException) { + throw IllegalArgumentException(e.message, e) + } catch (e: SQLiteConstraintException) { + throw IllegalArgumentException(e.message, e) + } finally { + source.invalidate() + } + if (batchDepth == 0) afterWrite() else batchWrote = true + result + } + + // --- tasks ------------------------------------------------------------------ + + private fun insertTask(values: ProviderValues): Long { + require(values[Tasks.ORIGINAL_INSTANCE_ID] == null && values[Tasks.ORIGINAL_INSTANCE_TIME] == null) { + "Modify an occurrence through ${Instances.PATH}, not by inserting one" + } + val writable = ProviderTaskWriter.writable(ProviderTable.TASKS, values, WRITABLE_TASK_COLUMNS) + val task = ProviderTaskWriter.newTask(writable, UUID.randomUUID().toString(), clock.now(), zone()) + requireWritableList(task.listId) + // The unique index cannot catch this: SQLite treats the masters' NULL recurrence_id as distinct. + require(database.tasks().byUid(task.listId, task.uid) == null) { "List ${task.listId} already has a task ${task.uid}" } + requireParent(task) + return store.insertEntity(task) + } + + private fun updateTask(taskId: Long, values: ProviderValues): Boolean { + val current = database.tasks().entity(taskId)?.takeUnless { it.isDeleted } ?: return false + requireWritableList(current.listId) + val edited = ProviderTaskWriter.patch(current, values, clock.now(), zone()) + if (edited == current) return true + val moved = edited.listId != current.listId + if (moved) { + requireWritableList(edited.listId) + requireFreeUid(edited.listId, current.uid) + } + if (edited.parentId != current.parentId || moved) requireParent(edited) + store.replaceEntity(current, edited) + // A subtree moves as one: a subtask has to be in its parent's list. + if (moved) store.subtaskIds(taskId).forEach { moveSubtree(it, edited.listId, hashSetOf(taskId)) } + return true + } + + private fun moveSubtree(taskId: Long, listId: Long, seen: MutableSet = HashSet()) { + if (!seen.add(taskId)) return + val current = database.tasks().entity(taskId)?.takeUnless { it.isDeleted } ?: return + if (current.listId != listId) { + requireFreeUid(listId, current.uid) + store.replaceEntity(current, current.copy(listId = listId, lastModified = clock.now(), isDirty = true)) + } + store.subtaskIds(taskId).forEach { moveSubtree(it, listId, seen) } + } + + /** + * [ids] with every parent before its subtasks, so a bulk move carries a + * subtree along before reaching its subtasks, whatever order they came in. + */ + private fun parentsFirst(ids: List): List { + if (ids.size < 2) return ids + fun depth(id: Long): Int { + val seen = HashSet() + var parent = database.tasks().entity(id)?.parentId + while (parent != null && seen.add(parent)) parent = database.tasks().entity(parent)?.parentId + return seen.size + } + val depths = ids.associateWith(::depth) + return ids.sortedBy(depths::getValue) + } + + private fun deleteTask(taskId: Long): Boolean { + val current = database.tasks().entity(taskId)?.takeUnless { it.isDeleted } ?: return false + requireWritableList(current.listId) + // An override deletes itself into an EXDATE on its master. + if (current.masterId != null) store.deleteTask(taskId) else store.deleteWithSubtasks(taskId) + return true + } + + // --- instances -------------------------------------------------------------- + + /** One occurrence: the row it reads from, its series, and when it was due to happen. */ + private class InstanceRow(val taskId: Long, val seriesId: Long, val originalTime: Instant?, val recurring: Boolean) + + private fun instances(selection: String?, args: Array?): List { + singleId(ProviderTable.INSTANCES, selection, args)?.let { return listOfNotNull(instanceRow(it)) } + return source.rows( + ProviderTable.INSTANCES, + listOf(Tasks.ID, Instances.TASK_ID, Instances.INSTANCE_ORIGINAL_TIME, Instances.IS_RECURRING), + selection, + args, + ).map { (id, taskId, original, recurring) -> + InstanceRow( + taskId = taskId as Long, + seriesId = ProviderIds.instanceSeries(id as Long), + originalTime = (original as Long?)?.let(Instant::fromEpochMilliseconds), + recurring = recurring == 1L, + ) + } + } + + /** [instanceId] read straight from the store, without rebuilding the snapshot. */ + private fun instanceRow(instanceId: Long): InstanceRow? { + val master = database.tasks().entity(ProviderIds.instanceSeries(instanceId)) + ?.takeIf { !it.isDeleted && it.masterId == null } ?: return null + val occurrences = seriesOccurrences(master, database.tasks().overrides(master.id), clock.now()) + ?: return InstanceRow(master.id, master.id, null, recurring = false) + .takeIf { ProviderIds.instanceSeconds(instanceId) == 0L } + val occurrence = occurrences.firstOrNull { ProviderIds.instance(master.id, it.anchor) == instanceId } ?: return null + return InstanceRow(occurrence.override?.id ?: master.id, master.id, occurrence.anchor, recurring = true) + } + + private fun updateInstance(row: InstanceRow, values: ProviderValues): Boolean { + val occurrence = row.originalTime + if (!row.recurring || occurrence == null) return updateTask(row.taskId, values) + val shown = database.tasks().entity(row.taskId)?.takeUnless { it.isDeleted } ?: return false + requireWritableList(shown.listId) + // A row read back carries every column; only what differs from it is a write. + val changed = ProviderTaskWriter.changedColumns(shown, values, zone()) + val moved = changed intersect TIME_COLUMNS + if (moved.isNotEmpty()) { + // A bad value reports itself rather than as a move. + ProviderTaskWriter.patch(shown, values.filterKeys { it in moved }, clock.now(), zone()) + throw IllegalArgumentException( + "One occurrence of a recurring task cannot be moved (${moved.sorted().joinToString()}); write the series through ${Tasks.PATH}", + ) + } + val content = values.filterKeys { it in changed && it !in COMPLETION_COLUMNS } + if (content.isNotEmpty()) { + updateTask(row.seriesId, content) + // A modified occurrence shows its own copy of the content, so it takes the write too. + if (row.taskId != row.seriesId) updateTask(row.taskId, content - Tasks.LIST_ID - Tasks.PARENT_ID) + } + if (changed.none { it in COMPLETION_COLUMNS }) return true + // The column the client changed decides, not the unchanged ones it carries along. + val change = ProviderTaskWriter.occurrenceChange(values.filterKeys { it in changed && it in COMPLETION_COLUMNS }) + ?: return true + val status = database.tasks().entity(row.taskId)?.status + when (change) { + OccurrenceChange.COMPLETE -> + if (status != TaskStatus.COMPLETED) store.setCompletedInstance(row.taskId, occurrence, true) + OccurrenceChange.CANCEL -> + if (status != TaskStatus.CANCELLED) store.setCancelledInstance(row.taskId, occurrence, true) + OccurrenceChange.REOPEN -> + if (status != TaskStatus.NEEDS_ACTION) store.setCompletedInstance(row.taskId, occurrence, false) + } + return true + } + + private fun deleteInstance(row: InstanceRow): Boolean { + val occurrence = row.originalTime + if (!row.recurring || occurrence == null) return deleteTask(row.taskId) + val task = database.tasks().entity(row.taskId)?.takeUnless { it.isDeleted } ?: return false + requireWritableList(task.listId) + if (task.masterId != null) store.deleteTask(task.id) else store.deleteOccurrence(task.id, occurrence) + return true + } + + // --- properties ------------------------------------------------------------- + + /** What a published property id stands for, read from the store. */ + private sealed interface PropertyRef { + val task: TaskEntity + + class AlarmRow(val alarm: TaskAlarmEntity, override val task: TaskEntity) : PropertyRef + class CategoryRow(val name: String, override val task: TaskEntity) : PropertyRef + } + + private fun propertyRef(propertyId: Long): PropertyRef? { + if (ProviderIds.isAlarm(propertyId)) { + val alarm = database.alarms().entity(ProviderIds.alarmRow(propertyId)) ?: return null + val task = database.tasks().entity(alarm.taskId)?.takeUnless { it.isDeleted } ?: return null + return PropertyRef.AlarmRow(alarm, task) + } + val task = database.tasks().entity(ProviderIds.categoryTask(propertyId))?.takeUnless { it.isDeleted } ?: return null + val name = ProviderIds.categories(task.id, task.categories).entries.firstOrNull { it.value == propertyId }?.key + ?: return null + return PropertyRef.CategoryRow(name, task) + } + + private fun insertProperty(values: ProviderValues): Long { + val v = Values(ProviderTaskWriter.writable(ProviderTable.PROPERTIES, values, values.keys)) + val taskId = requireNotNull(v.long(Properties.TASK_ID)) { "A property needs a ${Properties.TASK_ID}" } + val task = liveTask(taskId) + return when (v.string(Properties.MIMETYPE)) { + Alarm.MIMETYPE -> { + val alarm = alarm(TaskAlarmEntity(taskId = taskId, minutesBefore = 0), v, requireMinutes = true) + var id = 0L + store.writeAlarm(taskId) { id = database.alarms().insert(alarm) } + ProviderIds.alarm(id) + } + Category.MIMETYPE -> { + val name = requireNotNull(v.text(Category.NAME)) { "A category needs a ${Category.NAME}" } + setCategories(task, task.categories + name) + val categories = database.tasks().entity(taskId)!!.categories + ProviderIds.categories(taskId, categories).getValue(name) + } + else -> throw IllegalArgumentException("Only alarm and category properties can be written") + } + } + + private fun updateProperty(propertyId: Long, values: ProviderValues): Boolean { + val v = Values(ProviderTaskWriter.writable(ProviderTable.PROPERTIES, values, values.keys - Properties.PROPERTY_ID)) + val ref = propertyRef(propertyId) ?: return false + requireWritableList(ref.task.listId) + requireSame(v, Properties.TASK_ID, ref.task.id) + val mimetype = if (ref is PropertyRef.AlarmRow) Alarm.MIMETYPE else Category.MIMETYPE + if (Properties.MIMETYPE in v) require(v.string(Properties.MIMETYPE) == mimetype) { "A property's mimetype cannot change" } + when (ref) { + is PropertyRef.AlarmRow -> { + val edited = alarm(ref.alarm, v, requireMinutes = false) + if (edited != ref.alarm) store.writeAlarm(ref.task.id) { database.alarms().update(edited) } + } + is PropertyRef.CategoryRow -> if (Category.NAME in v) { + val name = requireNotNull(v.text(Category.NAME)) { "A category needs a ${Category.NAME}" } + setCategories(ref.task, ref.task.categories.map { if (it == ref.name) name else it }) + } + } + return true + } + + private fun deleteProperty(propertyId: Long): Boolean { + val ref = propertyRef(propertyId) ?: return false + requireWritableList(ref.task.listId) + when (ref) { + is PropertyRef.AlarmRow -> store.writeAlarm(ref.task.id) { database.alarms().delete(ref.alarm.id) } + is PropertyRef.CategoryRow -> setCategories(ref.task, ref.task.categories - ref.name) + } + return true + } + + private fun alarm(base: TaskAlarmEntity, v: Values, requireMinutes: Boolean): TaskAlarmEntity { + if (requireMinutes) requireNotNull(v.int(Alarm.MINUTES_BEFORE)) { "An alarm needs ${Alarm.MINUTES_BEFORE}" } + val reference = when (if (Alarm.REFERENCE in v) v.int(Alarm.REFERENCE) else null) { + null -> base.reference + Alarm.REFERENCE_DUE -> AlarmReference.DUE + Alarm.REFERENCE_START -> AlarmReference.START + else -> throw IllegalArgumentException("${Alarm.REFERENCE} must be ${Alarm.REFERENCE_DUE} (due) or ${Alarm.REFERENCE_START} (start)") + } + return base.copy( + minutesBefore = if (Alarm.MINUTES_BEFORE in v) requireNotNull(v.int(Alarm.MINUTES_BEFORE)) { + "${Alarm.MINUTES_BEFORE} cannot be null" + } else base.minutesBefore, + reference = reference, + message = if (Alarm.MESSAGE in v) v.text(Alarm.MESSAGE) else base.message, + ) + } + + private fun setCategories(task: TaskEntity, categories: List) { + val normalized = normalizeTags(categories) + if (normalized == task.categories) return + store.replaceEntity(task, task.copy(categories = normalized, lastModified = clock.now(), isDirty = true)) + } + + // --- checks ----------------------------------------------------------------- + + /** + * The ids [selection] matches. A row URI's plain ` = N` is checked + * against the store directly; anything else runs against the snapshot. + */ + private fun ids(table: ProviderTable, selection: String?, args: Array?): List { + singleId(table, selection, args)?.let { id -> + val exists = when (table) { + ProviderTable.TASKS -> database.tasks().entity(id)?.isDeleted == false + ProviderTable.PROPERTIES -> propertyRef(id) != null + else -> true + } + return listOfNotNull(id.takeIf { exists }) + } + return source.rows(table, listOf(table.idColumn), selection, args).map { it.single() as Long } + } + + private fun singleId(table: ProviderTable, selection: String?, args: Array?): Long? { + val match = SINGLE_ROW.matchEntire(selection ?: return null) ?: return null + if (match.groupValues[1] != table.idColumn) return null + val value = match.groupValues[2] + return when { + value == "?" -> args?.singleOrNull()?.trim()?.toLongOrNull() + args.isNullOrEmpty() -> value.toLongOrNull() + else -> null + } + } + + private fun requireFreeUid(listId: Long, uid: String) { + require(database.tasks().byUid(listId, uid)?.isDeleted != false) { "List $listId already has a task $uid" } + } + + private fun liveTask(taskId: Long): TaskEntity { + val task = requireNotNull(database.tasks().entity(taskId)?.takeUnless { it.isDeleted }) { "No task $taskId" } + requireWritableList(task.listId) + return task + } + + private fun requireWritableList(listId: Long) { + val list = requireNotNull(database.taskLists().entity(listId)) { "No task list $listId" } + if (list.isReadOnly) throw UnsupportedOperationException("Task list $listId is read-only") + } + + /** A parent has to be a live, top-level task in the same list, and not one of the task's own subtasks. */ + private fun requireParent(task: TaskEntity) { + var parentId = task.parentId ?: return + val parent = database.tasks().entity(parentId)?.takeUnless { it.isDeleted } + require(parent != null && parent.masterId == null) { "No task $parentId to be a parent" } + require(parent.listId == task.listId) { "A subtask has to be in its parent's list" } + val seen = HashSet() + while (true) { + require(parentId != task.id) { "Task ${task.id} cannot be its own ancestor" } + if (!seen.add(parentId)) break + parentId = database.tasks().entity(parentId)?.parentId ?: break + } + } + + private fun requireSame(v: Values, column: String, expected: Long) { + if (column in v) require(v.long(column) == expected) { "A property's $column cannot change" } + } + + private fun zone(): String = ZoneId.systemDefault().id +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/SnapshotDatabase.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/SnapshotDatabase.kt new file mode 100644 index 0000000..5e5d2ce --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/SnapshotDatabase.kt @@ -0,0 +1,88 @@ +package de.jeanlucmakiola.agendula.data.tasks.provider + +import android.content.ContentValues +import android.database.Cursor +import android.database.MatrixCursor +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteQueryBuilder +import android.os.Build +import androidx.core.database.sqlite.transaction + +/** + * An in-memory SQLite copy of a [ProviderSnapshot], so a client's own + * `selection`, `selectionArgs` and `sortOrder` run as SQL exactly as they would + * against OpenTasks. Holds only what the caller may already read. + * + * Not thread-safe; [ProviderSnapshotSource] serialises access. + */ +internal class SnapshotDatabase { + + private val db: SQLiteDatabase = SQLiteDatabase.create(null).apply { + ProviderTable.entries.forEach { table -> + execSQL( + "CREATE TABLE ${table.path} (" + + table.columns.joinToString { (name, type) -> "$name $type" } + ")", + ) + } + } + + fun load(snapshot: ProviderSnapshot) { + db.transaction { + ProviderTable.entries.forEach { table -> + delete(table.path, null, null) + snapshot.rows[table].orEmpty().forEach { insertOrThrow(table.path, null, it.toContentValues()) } + } + } + } + + /** + * Runs the query and copies the result out, so the cursor a client holds + * survives the next [load]. + */ + fun query( + table: ProviderTable, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String?, + ): Cursor { + val builder = SQLiteQueryBuilder().apply { + tables = table.path + isStrict = true + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + isStrictColumns = true + isStrictGrammar = true + } + projectionMap = table.columns.associate { (name, _) -> name to name } + } + builder.query(db, projection, selection, selectionArgs, null, null, sortOrder).use { source -> + val copy = MatrixCursor(source.columnNames, source.count) + while (source.moveToNext()) { + copy.addRow( + Array(source.columnCount) { column -> + when (source.getType(column)) { + Cursor.FIELD_TYPE_NULL -> null + Cursor.FIELD_TYPE_INTEGER -> source.getLong(column) + Cursor.FIELD_TYPE_FLOAT -> source.getDouble(column) + Cursor.FIELD_TYPE_BLOB -> source.getBlob(column) + else -> source.getString(column) + } + }, + ) + } + return copy + } + } + + private fun ProviderRow.toContentValues() = ContentValues(size).also { values -> + forEach { (key, value) -> + when (value) { + null -> values.putNull(key) + is Long -> values.put(key, value) + is Int -> values.put(key, value) + is String -> values.put(key, value) + else -> values.put(key, value.toString()) + } + } + } +} 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 34a9b37..f97b16f 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSource.kt @@ -2,8 +2,8 @@ package de.jeanlucmakiola.agendula.data.tasks.room import androidx.room.InvalidationTracker import de.jeanlucmakiola.agendula.data.tasks.TaskQuery -import de.jeanlucmakiola.agendula.data.tasks.TaskReminder -import de.jeanlucmakiola.agendula.data.tasks.editable +import de.jeanlucmakiola.agendula.domain.TaskReminder +import de.jeanlucmakiola.agendula.domain.TaskReminderOrder import de.jeanlucmakiola.agendula.data.tasks.TaskWriteFailedException import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource import de.jeanlucmakiola.agendula.domain.Task @@ -31,7 +31,51 @@ private val SERIES_LINES = listOf("EXDATE", "RDATE", "RRULE", "RECURRENCE-ID", " /** Enough to count every occurrence before a split point without bounding a real series. */ internal const val SPLIT_COUNT_CEILING = 100_000 -private val OBSERVED_TABLES = arrayOf("tasks", "task_lists", "task_alarms", "accounts") +/** The window every read of the store expands a series over. */ +internal fun expansionWindow(now: Instant) = ExpansionWindow( + from = now - WINDOW_BACK, + until = now + WINDOW_FORWARD, + pivot = now, +) + +/** + * One occurrence's resolved start and due. A timed series keeps each + * occurrence's duration; a due-anchored one has no start to offset from, so + * the anchor *is* the due date. + */ +internal fun occurrenceTimes(master: TaskEntity, anchor: Instant): Pair { + if (master.dtstart == null) return null to anchor + val length = master.due?.let { it - master.dtstart } + return anchor to length?.let { anchor + it } +} + +/** One occurrence of a series: its anchor, the override replacing it if any, and its resolved times. */ +internal class SeriesOccurrence( + val anchor: Instant, + val override: TaskEntity?, + val start: Instant?, + val due: Instant?, + val distance: Int?, +) + +/** + * Every occurrence of [master] inside the expansion window, with any + * `RECURRENCE-ID` override substituted for the occurrence it replaces, or + * `null` when [master] does not recur. + */ +internal fun seriesOccurrences(master: TaskEntity, overrides: List, now: Instant): List? { + val spec = master.recurrenceSpec() ?: return null + val anchors = RecurrenceExpander.expand(spec, expansionWindow(now)) + val distances = RecurrenceExpander.distancesFromCurrent(anchors, now) + val byAnchor = overrides.associateBy { it.recurrenceId } + return anchors.mapIndexed { index, anchor -> + val override = byAnchor[anchor] + val (start, due) = if (override != null) override.dtstart to override.due else occurrenceTimes(master, anchor) + SeriesOccurrence(anchor, override, start, due, distances.getOrNull(index)) + } +} + +internal val OBSERVED_TABLES = arrayOf("tasks", "task_lists", "task_alarms", "accounts") /** Distinct alarm texts whose `ACKNOWLEDGED` is remembered between scheduler passes. */ private const val ACK_CACHE_SIZE = 512 @@ -104,6 +148,12 @@ class RoomTasksDataSource @Inject constructor( .flatMap { occurrencesOf(it, tasks.overrides(it.task.id), now) } } + /** One transaction, so a failure part-way leaves the subtree whole. */ + override fun deleteWithSubtasks(taskId: Long) = tx { super.deleteWithSubtasks(taskId) } + + override fun subtaskIds(parentTaskId: Long): List = + tasks.subtasks(parentTaskId).map { it.task.id }.filter { it != parentTaskId } + override fun exportTasks(listId: Long): List = tasks.exportTasks(listId).map(RoomTaskMapper::exportTask) @@ -125,9 +175,6 @@ class RoomTasksDataSource @Inject constructor( } } - override fun alarms(): Map = - reminders().mapNotNull { (id, list) -> list.editable()?.let { id to it } }.toMap() - /** * Every occurrence of [row] inside the expansion window, with any * `RECURRENCE-ID` override substituted for the occurrence it replaces. @@ -136,54 +183,31 @@ class RoomTasksDataSource @Inject constructor( * [Task.occurrenceStart], so it keys and edits by task id exactly as before. */ private fun occurrencesOf(row: TaskRow, overrides: List, now: Instant): List { - val spec = row.task.recurrenceSpec() ?: return listOf(RoomTaskMapper.task(row)) - val window = ExpansionWindow( - from = now - WINDOW_BACK, - until = now + WINDOW_FORWARD, - pivot = now, - ) - val anchors = RecurrenceExpander.expand(spec, window) - if (anchors.isEmpty()) return emptyList() - - val distances = RecurrenceExpander.distancesFromCurrent(anchors, now) - val byAnchor = overrides.associateBy { it.recurrenceId } - - return anchors.mapIndexedNotNull { index, anchor -> - val override = byAnchor[anchor] + val occurrences = seriesOccurrences(row.task, overrides, now) ?: return listOf(RoomTaskMapper.task(row)) + return occurrences.map { occurrence -> + val override = occurrence.override if (override != null) { RoomTaskMapper.task( row = row.copy(task = override), - occurrenceStart = anchor, - start = override.dtstart, - due = override.due, - distanceFromCurrent = distances[index], + occurrenceStart = occurrence.anchor, + start = occurrence.start, + due = occurrence.due, + distanceFromCurrent = occurrence.distance, seriesId = row.task.id, recurrenceRule = row.task.rrule, ) } else { - val (start, due) = occurrenceTimes(row.task, anchor) RoomTaskMapper.task( row = row, - occurrenceStart = anchor, - start = start, - due = due, - distanceFromCurrent = distances[index], + occurrenceStart = occurrence.anchor, + start = occurrence.start, + due = occurrence.due, + distanceFromCurrent = occurrence.distance, ) } } } - /** - * One occurrence's resolved start and due. A timed series keeps each - * occurrence's duration; a due-anchored one has no start to offset from, so - * the anchor *is* the due date. - */ - private fun occurrenceTimes(master: TaskEntity, anchor: Instant): Pair { - if (master.dtstart == null) return null to anchor - val length = master.due?.let { it - master.dtstart } - return anchor to length?.let { anchor + it } - } - // --- writes --------------------------------------------------------------- override fun insertTask(form: TaskForm, reminders: List?): Long { @@ -199,26 +223,24 @@ class RoomTasksDataSource @Inject constructor( } /** - * [leads] as [taskId]'s before-due reminders, inside the caller's - * transaction. A start-relative row is kept: the editor never shows one. + * [reminders] as [taskId]'s own, inside the caller's transaction. * * ⚠️ Part of the task write rather than a second call after it. Once * reminders sync, a sync landing between the two would upload the task * without them, or with the ones it is about to lose. */ - private fun dueLeads(taskId: Long): List = - alarms.forTask(taskId).filter { it.reference == AlarmReference.DUE }.map { it.minutesBefore }.sorted() - - private fun writeDueReminders(taskId: Long, leads: List?) { - if (leads == null) return - val kept = alarms.forTask(taskId).filter { it.reference == AlarmReference.START }.map { it.asReminder() } - alarms.syncForTask(taskId, kept + leads.distinct().sorted().map { TaskReminder(it) }) + private fun writeReminders(taskId: Long, reminders: List?) { + if (reminders == null) return + alarms.syncForTask(taskId, reminders) } - override fun updateTask(taskId: Long, form: TaskForm, dueReminders: List?) = tx { + private fun storedReminders(taskId: Long): List = + alarms.forTask(taskId).map { it.asReminder() }.sortedWith(TaskReminderOrder) + + override fun updateTask(taskId: Long, form: TaskForm, reminders: List?) = tx { val current = tasks.entity(taskId) ?: throw TaskWriteFailedException("update task $taskId") val edited = TaskFormWriter.apply(current, form, clock.now(), zone()) - writeDueReminders(taskId, dueReminders) + writeReminders(taskId, reminders) if (current.masterId == null && edited.listId != current.listId) { if (lists.exists(edited.listId) == 0) throw TaskWriteFailedException("move task: no list ${edited.listId}") move(current, TaskFormWriter.withRule(edited, form.rrule)) @@ -257,20 +279,23 @@ class RoomTasksDataSource @Inject constructor( plan.tombstone?.let { tasks.insert(it) } } - override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List?) = tx { + override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List?) = tx { val master = seriesMaster(seriesId) // ⚠️ The overrides too, where they still carry the series' reminders. // A fork copies them as a snapshot — completing one occurrence is // enough — and each override uploads its own VALARMs, so leaving them // would keep ringing those occurrences at the old lead everywhere. One // whose reminders were set for that occurrence alone keeps them. - if (dueReminders != null) { - val before = dueLeads(master.id) + if (reminders != null) { + val before = storedReminders(master.id) tasks.overrides(master.id) - .filter { dueLeads(it.id) == before } - .forEach { writeDueReminders(it.id, dueReminders) } + .filter { storedReminders(it.id) == before } + .forEach { + writeReminders(it.id, reminders) + if (form.remindersChosen && reminders.isEmpty()) alarms.setRemindersOff(it.id, true) + } } - writeDueReminders(master.id, dueReminders) + writeReminders(master.id, reminders) val (start, due) = resolvedTimes(master, occurrenceStart) val edited = TaskFormWriter.seriesEdit(master, start, due, form, clock.now(), zone()) // A moved or re-ruled series no longer generates the anchors its @@ -286,14 +311,14 @@ class RoomTasksDataSource @Inject constructor( writes.onWritten(setOf(master.listId, edited.listId)) } - override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List?): Long { + override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List?): Long { var created = seriesId tx { val master = seriesMaster(seriesId) val spec = master.recurrenceSpec() val anchor = master.recurrenceAnchor if (spec == null || anchor == null || occurrenceStart <= anchor) { - updateSeries(seriesId, occurrenceStart, form, dueReminders) + updateSeries(seriesId, occurrenceStart, form, reminders) return@tx } val now = clock.now() @@ -338,7 +363,7 @@ class RoomTasksDataSource @Inject constructor( val newId = tasks.insert(fresh) // A new UID, so a new resource: the alarms are copies, not the same ones. alarms.copyToTask(master.id, newId, sameResource = false) - writeDueReminders(newId, dueReminders) + writeReminders(newId, reminders) val unmoved = fresh.dtstart == start && fresh.due == due && sameRule tasks.overrides(master.id) @@ -373,8 +398,7 @@ class RoomTasksDataSource @Inject constructor( val master = tasks.entity(seriesId) ?: return@tx val anchor = master.recurrenceAnchor if (anchor == null || occurrenceStart <= anchor) { - tasks.subtasks(seriesId).forEach { deleteTask(it.task.id) } - deleteTask(seriesId) + deleteWithSubtasks(seriesId) return@tx } tasks.update(TaskFormWriter.endedBefore(master, occurrenceStart, clock.now(), zone())) @@ -403,7 +427,7 @@ class RoomTasksDataSource @Inject constructor( * which is the model least compatible with sync; the override shares its * master's UID, which is exactly what makes it an override. */ - override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List?) = tx { + override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List?) = tx { val master = tasks.entity(taskId) ?: throw TaskWriteFailedException("update instance $taskId") val now = clock.now() val existing = tasks.override(taskId, occurrenceStart) @@ -412,7 +436,7 @@ class RoomTasksDataSource @Inject constructor( // master's, whatever the form was carrying. val edited = TaskFormWriter.apply(existing, form, now, zone()) tasks.update(edited.copy(listId = master.listId, parentId = master.parentId)) - writeDueReminders(existing.id, dueReminders) + writeReminders(existing.id, reminders) writes.onWritten(setOf(master.listId)) return@tx } @@ -429,18 +453,10 @@ class RoomTasksDataSource @Inject constructor( // The occurrence starts out with the series' reminders and then takes // the form's — the master's own are never touched. alarms.copyToTask(taskId, id, sameResource = true) - writeDueReminders(id, dueReminders) + writeReminders(id, reminders) writes.onWritten(setOf(master.listId)) } - /** Through [setReminders], so a synced task is owed the PUT and the others keep their identity. */ - override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) { - val current = alarms.forTask(taskId).map { it.asReminder() } - val editable = current.indexOfLast { !it.fromStart } - val others = if (editable < 0) current else current.filterIndexed { index, _ -> index != editable } - setReminders(taskId, others + listOfNotNull(minutesBeforeDue?.let { TaskReminder(it) })) - } - override fun setReminders(taskId: Long, reminders: List) = tx { if (!alarms.syncForTask(taskId, reminders)) return@tx // The reminders are part of the VTODO now, so changing them is an edit. @@ -566,6 +582,47 @@ class RoomTasksDataSource @Inject constructor( writes.onWritten(setOf(current.listId)) } + // --- whole-row writes, for the published provider -------------------------- + + /** Inserts [entity] as it stands, for a client that wrote the row itself. */ + internal fun insertEntity(entity: TaskEntity): Long { + if (lists.exists(entity.listId) == 0) throw TaskWriteFailedException("insert task: no list ${entity.listId}") + return tasks.insert(entity).also { writes.onWritten(setOf(entity.listId)) } + } + + /** + * Writes [edited] over [current] under the rules [updateTask] and + * [updateSeries] keep: a task moves lists through [TaskMove], an override + * keeps its master's list and parent, and a series whose anchor or rule + * changed drops the overrides it no longer generates. + */ + internal fun replaceEntity(current: TaskEntity, edited: TaskEntity) { + tx { + val reshaped = current.masterId == null && current.recurrenceSpec() != null && + (edited.dtstart != current.dtstart || edited.due != current.due || edited.rrule != current.rrule) + if (reshaped) tasks.overrides(current.id).forEach { tasks.delete(it.id) } + when { + current.masterId != null -> tasks.update(edited.copy(listId = current.listId, parentId = current.parentId)) + edited.listId != current.listId -> { + if (lists.exists(edited.listId) == 0) throw TaskWriteFailedException("move task: no list ${edited.listId}") + move(current, edited) + } + else -> tasks.update(edited) + } + } + writes.onWritten(setOf(current.listId, edited.listId)) + } + + /** Adds, changes or removes one reminder, and marks its task changed so the reminder syncs. */ + internal fun writeAlarm(taskId: Long, write: () -> Unit) { + val task = tasks.entity(taskId) ?: throw TaskWriteFailedException("alarm on task $taskId") + tx { + write() + tasks.update(task.copy(lastModified = clock.now(), isDirty = true)) + } + writes.onWritten(setOf(task.listId)) + } + override fun createLocalList(name: String, color: Int): Long = lists.insert(TaskListEntity(name = name.trim(), color = color)) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskAlarmDao.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskAlarmDao.kt index a7a5539..2a1f1f8 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskAlarmDao.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskAlarmDao.kt @@ -5,7 +5,7 @@ import androidx.room.Insert import androidx.room.Query import androidx.room.Transaction import androidx.room.Update -import de.jeanlucmakiola.agendula.data.tasks.TaskReminder +import de.jeanlucmakiola.agendula.domain.TaskReminder import de.jeanlucmakiola.agendula.domain.ical.ICalParser import de.jeanlucmakiola.agendula.domain.ical.ICalValues import kotlin.time.Instant @@ -25,18 +25,21 @@ interface TaskAlarmDao { @Query("SELECT * FROM task_alarms WHERE task_id IN (:taskIds) ORDER BY id") fun forTasks(taskIds: List): List + @Query("SELECT * FROM task_alarms WHERE id = :id") + fun entity(id: Long): TaskAlarmEntity? + @Insert fun insert(alarm: TaskAlarmEntity): Long + @Update + fun update(alarm: TaskAlarmEntity): Int + @Query("DELETE FROM task_alarms WHERE id = :id") fun delete(id: Long): Int @Query("DELETE FROM task_alarms WHERE task_id = :taskId") fun deleteForTask(taskId: Long): Int - @Update - fun update(alarm: TaskAlarmEntity) - /** See [TaskEntity.remindersOff]. */ @Query("UPDATE tasks SET reminders_off = :off WHERE id = :taskId") fun setRemindersOff(taskId: Long, off: Boolean): Int 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 b7309e9..52d28f4 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 @@ -105,6 +105,10 @@ interface TaskDao { @Query("SELECT * FROM tasks WHERE list_id = :listId AND is_deleted = 0 AND master_id IS NULL") fun exportTasks(listId: Long): List + /** Every row but the tombstones, overrides included — what the published provider lays out. */ + @Query("SELECT * FROM tasks WHERE is_deleted = 0") + fun live(): List + @Query("SELECT * FROM tasks WHERE is_dirty = 1") fun dirty(): List diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriter.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriter.kt index 18d4392..a73b2c0 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriter.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriter.kt @@ -63,6 +63,7 @@ object TaskFormWriter { isAllDay = form.isAllDay, timezone = if (timed) tzId else null, parentId = form.parentId?.takeIf { it > 0 }, + remindersOff = if (form.remindersChosen) form.reminders.isEmpty() else current.remindersOff, lastModified = now, isDirty = true, ) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImport.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImport.kt index e50e558..9999e14 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImport.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImport.kt @@ -7,7 +7,7 @@ import androidx.datastore.preferences.core.edit import de.jeanlucmakiola.agendula.data.di.ExternalStore import de.jeanlucmakiola.agendula.data.di.IoDispatcher import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver -import de.jeanlucmakiola.agendula.data.tasks.TaskReminder +import de.jeanlucmakiola.agendula.domain.TaskReminder import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource import de.jeanlucmakiola.agendula.data.tasks.room.toAlarmEntity import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskForm.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskForm.kt index 9adec18..9bf41ec 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskForm.kt @@ -18,8 +18,10 @@ data class TaskForm( val parentId: Long? = null, /** 0..100 progress; `null` leaves it unset (cleared). */ val percentComplete: Int? = null, - /** The task's own reminders, in minutes before [due]; empty = the list's or app default applies. */ - val reminders: List = emptyList(), + /** The task's own reminders; empty = the list's or app default applies, unless [remindersChosen]. */ + val reminders: List = emptyList(), + /** Whether [reminders] are exactly the task's, as the editor showed them: empty is then none. */ + val remindersChosen: Boolean = false, val location: String? = null, val url: String? = null, val tags: List = emptyList(), @@ -30,14 +32,14 @@ data class TaskForm( if (title.isBlank()) add(TaskFormError.BLANK_TITLE) if (listId <= 0L) add(TaskFormError.NO_LIST) if (start != null && due != null && due < start) add(TaskFormError.DUE_BEFORE_START) - if (reminders.isNotEmpty() && due == null) add(TaskFormError.REMINDER_WITHOUT_DUE) + if (reminders.isNotEmpty() && start == null && due == null) add(TaskFormError.REMINDER_WITHOUT_DATE) if (rrule != null && start == null && due == null) add(TaskFormError.RECURRENCE_WITHOUT_DATE) } val isValid: Boolean get() = validate().isEmpty() } -enum class TaskFormError { BLANK_TITLE, NO_LIST, DUE_BEFORE_START, REMINDER_WITHOUT_DUE, RECURRENCE_WITHOUT_DATE } +enum class TaskFormError { BLANK_TITLE, NO_LIST, DUE_BEFORE_START, REMINDER_WITHOUT_DATE, RECURRENCE_WITHOUT_DATE } /** * Optional edit-form sections that can be shown or tucked behind "More fields" diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskReminder.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskReminder.kt new file mode 100644 index 0000000..ace8bef --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskReminder.kt @@ -0,0 +1,85 @@ +package de.jeanlucmakiola.agendula.domain + +import de.jeanlucmakiola.floret.reminders.ReminderLead +import kotlin.time.Instant + +const val DAY_MINUTES = 24 * 60 + +/** + * The earliest lead an all-day reminder can have: 00:01 on the day. On an + * all-day task a lead that is not whole days counts back from the date's + * midnight, so `-540` is 09:00 on the day — see [TaskReminder.allDay]. + */ +const val MIN_ALL_DAY_LEAD = -(DAY_MINUTES - 1) + +/** A stored reminder: how long before the start ([fromStart]) or the due date. */ +data class TaskReminder( + val minutesBefore: Int, + val fromStart: Boolean = false, + /** + * When another client last dismissed this alarm (RFC 9074 `ACKNOWLEDGED`): + * a trigger at or before it has been dealt with and is not fired here. + * Never part of what a reminder *is* — see [lead]. + */ + val acknowledgedAt: Instant? = null, +) { + /** The reminder without its bookkeeping, for comparing two of them. */ + val lead: TaskReminder get() = if (acknowledgedAt == null) this else copy(acknowledgedAt = null) + + /** This reminder read as an all-day one. */ + val allDay: AllDayReminder get() = AllDayReminder.of(minutesBefore) + + /** This reminder's lead rounded to the nearest whole day, as the earlier all-day leads were. */ + fun inWholeDays(): TaskReminder = + copy(minutesBefore = (minutesBefore.coerceAtLeast(0) + DAY_MINUTES / 2) / DAY_MINUTES * DAY_MINUTES) + + companion object { + /** + * The all-day reminder [daysBefore] the date at [minuteOfDay], or at the + * device's all-day time when that is null. Midnight would read back as + * the latter, so it is 00:01 — see [allDayMinuteOfDay]. + */ + fun allDay(daysBefore: Int, minuteOfDay: Int?, fromStart: Boolean = false) = + TaskReminder(daysBefore * DAY_MINUTES - (minuteOfDay?.let(::allDayMinuteOfDay) ?: 0), fromStart) + + /** The time an all-day reminder can name: any but midnight, which is a whole-day lead. */ + fun allDayMinuteOfDay(minuteOfDay: Int): Int = minuteOfDay.coerceAtLeast(1) + } +} + +/** An all-day reminder as the day it rings on and, when it names one, its time. */ +data class AllDayReminder(val daysBefore: Int, val minuteOfDay: Int?) { + companion object { + /** [minutesBefore] on an all-day task; anything after the day is clamped to its last minute. */ + fun of(minutesBefore: Int): AllDayReminder { + val minutes = minutesBefore.coerceAtLeast(MIN_ALL_DAY_LEAD) + if (minutes % DAY_MINUTES == 0) return AllDayReminder(minutes / DAY_MINUTES, minuteOfDay = null) + val days = Math.floorDiv(minutes, DAY_MINUTES) + 1 + return AllDayReminder(days, minuteOfDay = days * DAY_MINUTES - minutes) + } + } +} + +/** A canonical order, for comparing two sets of reminders. */ +val TaskReminderOrder: Comparator = + compareBy({ !it.fromStart }, { -it.minutesBefore }) + +/** + * The order the editor and the detail screen list reminders in: start's first, + * then earliest first. An all-day reminder without a time rings at [allDayMinuteOfDay]. + */ +fun taskReminderDisplayOrder(allDay: Boolean, allDayMinuteOfDay: Int): Comparator = + compareBy({ !it.fromStart }, { reminder -> + if (!allDay) { + -reminder.minutesBefore + } else { + val (days, minuteOfDay) = reminder.allDay + (minuteOfDay ?: allDayMinuteOfDay) - days * DAY_MINUTES + } + }) + +/** A default reminder's lead as a reminder of the task's own. */ +fun ReminderLead.asTaskReminder(): TaskReminder = TaskReminder(minutes, fromStart) + +/** This reminder's lead, as default reminders are kept. */ +fun TaskReminder.asReminderLead(): ReminderLead = ReminderLead(minutesBefore, fromStart) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ReminderFormatting.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ReminderFormatting.kt index 9fabff3..2781143 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ReminderFormatting.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ReminderFormatting.kt @@ -5,6 +5,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.domain.DAY_MINUTES +import de.jeanlucmakiola.agendula.domain.TaskReminder import de.jeanlucmakiola.floret.reminders.ReminderUnit /** Common reminder lead times offered as quick picks in the reminder pickers. */ @@ -33,9 +35,57 @@ fun reminderLeadTimeLabel(minutes: Int, allDay: Boolean = false): String = when minutes <= 0 -> stringResource(R.string.reminder_at_due) minutes % 10_080 == 0 -> pluralStringResource(R.plurals.reminder_weeks, minutes / 10_080, minutes / 10_080) - minutes % 1_440 == 0 -> - pluralStringResource(R.plurals.reminder_days, minutes / 1_440, minutes / 1_440) + minutes % DAY_MINUTES == 0 -> + pluralStringResource(R.plurals.reminder_days, minutes / DAY_MINUTES, minutes / DAY_MINUTES) minutes % 60 == 0 -> pluralStringResource(R.plurals.reminder_hours, minutes / 60, minutes / 60) else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes) } + +/** + * [reminderLeadTimeLabel] naming the date the lead counts from, for a task + * that has both: "15 minutes before the start", "On the due date". + */ +@Composable +fun anchoredLeadTimeLabel(minutes: Int, allDay: Boolean, fromStart: Boolean): String { + @Composable + fun plural(start: Int, due: Int, count: Int) = + pluralStringResource(if (fromStart) start else due, count, count) + return when { + minutes <= 0 && allDay -> + stringResource(if (fromStart) R.string.reminder_on_start_date else R.string.reminder_on_due_date) + minutes <= 0 -> stringResource(if (fromStart) R.string.reminder_at_start else R.string.reminder_at_due_time) + minutes % 10_080 == 0 -> + plural(R.plurals.reminder_weeks_before_start, R.plurals.reminder_weeks_before_due, minutes / 10_080) + minutes % DAY_MINUTES == 0 -> + plural(R.plurals.reminder_days_before_start, R.plurals.reminder_days_before_due, minutes / DAY_MINUTES) + minutes % 60 == 0 -> + plural(R.plurals.reminder_hours_before_start, R.plurals.reminder_hours_before_due, minutes / 60) + else -> plural(R.plurals.reminder_minutes_before_start, R.plurals.reminder_minutes_before_due, minutes) + } +} + +/** A lead's label, naming its date when [anchored]. */ +@Composable +fun reminderLabel(minutes: Int, allDay: Boolean, fromStart: Boolean, anchored: Boolean): String = + if (anchored) anchoredLeadTimeLabel(minutes, allDay, fromStart) else reminderLeadTimeLabel(minutes, allDay) + +/** + * One of a task's own reminders in one line, naming its date when [anchored]. + * An all-day one names its day and the time it rings at — its own, or + * [allDayMinuteOfDay] when it has none. + */ +@Composable +fun taskReminderLabel(reminder: TaskReminder, allDay: Boolean, allDayMinuteOfDay: Int, anchored: Boolean): String { + if (!allDay) return reminderLabel(reminder.minutesBefore, false, reminder.fromStart, anchored) + val (days, minuteOfDay) = reminder.allDay + return stringResource( + R.string.reminder_at_time, + reminderLabel(days * DAY_MINUTES, true, reminder.fromStart, anchored), + formatMinuteOfDay(minuteOfDay ?: allDayMinuteOfDay, LocalUse24HourFormat.current), + ) +} + +@StringRes +fun reminderAnchorLabel(fromStart: Boolean): Int = + if (fromStart) R.string.reminder_anchor_start else R.string.reminder_anchor_due diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ReminderLeadPicker.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ReminderLeadPicker.kt index cb1f4ea..c1cbd1a 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ReminderLeadPicker.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ReminderLeadPicker.kt @@ -1,12 +1,13 @@ package de.jeanlucmakiola.agendula.ui.common +import de.jeanlucmakiola.floret.components.CustomAmountEditor import de.jeanlucmakiola.floret.components.FullScreenPicker import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.positionOf -import de.jeanlucmakiola.floret.components.DialogAmountField import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.SelectedCheck -import de.jeanlucmakiola.floret.reminders.reminderOverrideForMinutes +import de.jeanlucmakiola.floret.reminders.ReminderLead +import de.jeanlucmakiola.floret.reminders.reminderOverrideForLeads import androidx.compose.foundation.layout.height import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember @@ -16,27 +17,19 @@ import de.jeanlucmakiola.floret.reminders.decomposeReminderMinutes import androidx.compose.animation.AnimatedVisibility import de.jeanlucmakiola.floret.identity.collapseExit import de.jeanlucmakiola.floret.identity.expandEnter -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -46,8 +39,10 @@ import de.jeanlucmakiola.agendula.R * Reminder lead-time picker, full-screen: the grouped list of preset lead times * plus a "Custom" row that expands an inline amount field + unit selector. * [allowInherit] adds a "Use default" row (per-list overrides); [allowNone] adds - * a "None" row; [allDay] switches to day-scale presets and wording. Returns the - * choice as a [ReminderOverride]. Ported from Calendula's ReminderDefaultPicker. + * a "None" row; [allDay] switches to day-scale presets and wording, and its + * custom amounts to days and weeks. [header] sits above the rows, for whatever + * else the caller's reminder carries. Returns the choice as a + * [ReminderOverride]. Ported from Calendula's ReminderDefaultPicker. */ @Composable fun ReminderLeadPicker( @@ -59,6 +54,7 @@ fun ReminderLeadPicker( onDismiss: () -> Unit, allDay: Boolean = false, presets: List = if (allDay) ALLDAY_REMINDER_PRESETS else REMINDER_PRESETS, + header: (@Composable ColumnScope.() -> Unit)? = null, ) { // Agendula is single-reminder: an override carries a one-element list, so // take the single value for this single-select picker. @@ -68,7 +64,7 @@ fun ReminderLeadPicker( var customExpanded by rememberSaveable { mutableStateOf(false) } var amountText by rememberSaveable { mutableStateOf(seed.amount?.toString() ?: "") } - var unit by rememberSaveable { mutableStateOf(seed.unit) } + var unit by rememberSaveable { mutableStateOf(seed.unit.takeIf { it in reminderUnits(allDay) } ?: reminderUnits(allDay).first()) } val options = buildList { if (allowInherit) add(ReminderOverride.Inherit) @@ -78,6 +74,7 @@ fun ReminderLeadPicker( val rowCount = options.size + 1 // + the custom row FullScreenPicker(title = title, onDismiss = onDismiss) { + header?.invoke(this) options.forEachIndexed { index, option -> val isSelected = option == selected GroupedRow( @@ -112,6 +109,8 @@ fun ReminderLeadPicker( onAmountChange = { amountText = it }, unit = unit, onUnitChange = { unit = it }, + units = reminderUnits(allDay), + allDay = allDay, onConfirm = { minutes -> onSelect(ReminderOverride.Minutes(listOf(minutes))) onDismiss() @@ -129,7 +128,9 @@ fun ReminderLeadPicker( * Clearing the last time falls back to "Use default" on a per-list picker and to * "No reminder" on the global one. Changes apply live; the user leaves via back. * [allDay] switches to day-scale presets, and [leadTimeSummary] adds a second - * line to each lead-time row. Ported from Calendula's ReminderDefaultPicker. + * line to each lead-time row. A start / due toggle picks which date the rows + * count from, and the choice carries it ([ReminderOverride.Minutes.fromStart]). + * Ported from Calendula's ReminderDefaultPicker. */ @Composable fun ReminderDefaultPicker( @@ -149,24 +150,27 @@ fun ReminderDefaultPicker( LaunchedEffect(selected) { if (!userEdited) current = selected } val inherits = current is ReminderOverride.Inherit val isNone = current is ReminderOverride.None - val selectedMinutes = (current as? ReminderOverride.Minutes)?.minutes.orEmpty() + val selectedLeads = (current as? ReminderOverride.Minutes)?.leads.orEmpty() + var fromStart by rememberSaveable { mutableStateOf(true) } + val selectedMinutes = selectedLeads.filter { it.fromStart == fromStart }.map { it.minutes } // Custom values seen this session keep their row once unchecked. - val seenCustom = remember { mutableSetOf() } - seenCustom += selectedMinutes.filter { it !in presets } - val rows = presets + seenCustom.sorted() + val seenCustom = remember { mutableSetOf() } + seenCustom += selectedLeads.filter { it.minutes !in presets } + val rows = presets + seenCustom.filter { it.fromStart == fromStart }.map { it.minutes }.sorted() var customExpanded by rememberSaveable { mutableStateOf(false) } var amountText by rememberSaveable { mutableStateOf("") } - var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) } + var unit by rememberSaveable { mutableStateOf(reminderUnits(allDay).first()) } fun apply(override: ReminderOverride) { userEdited = true current = override onSelect(override) } - fun emit(minutes: List) = apply(reminderOverrideForMinutes(minutes, allowInherit)) + fun emit(leads: List) = apply(reminderOverrideForLeads(leads, allowInherit)) + fun lead(minute: Int) = ReminderLead(minute, fromStart) fun toggle(minute: Int) = - emit(if (minute in selectedMinutes) selectedMinutes - minute else selectedMinutes + minute) + emit(if (minute in selectedMinutes) selectedLeads - lead(minute) else selectedLeads + lead(minute)) FullScreenPicker(title = title, onDismiss = onDismiss) { if (allowInherit) { @@ -186,6 +190,8 @@ fun ReminderDefaultPicker( onClick = { apply(ReminderOverride.None) }, ) Spacer(Modifier.height(24.dp)) + ReminderAnchorToggle(fromStart = fromStart, onChange = { fromStart = it }) + Spacer(Modifier.height(16.dp)) val rowCount = rows.size + 1 // + the custom row rows.forEachIndexed { index, minute -> val checked = minute in selectedMinutes @@ -213,8 +219,10 @@ fun ReminderDefaultPicker( onAmountChange = { amountText = it }, unit = unit, onUnitChange = { unit = it }, + units = reminderUnits(allDay), + allDay = allDay, onConfirm = { minutes -> - emit(selectedMinutes + minutes) + emit(selectedLeads + lead(minutes)) amountText = "" customExpanded = false }, @@ -223,57 +231,54 @@ fun ReminderDefaultPicker( } } +/** Which date a reminder counts back from. */ +@Composable +fun ReminderAnchorToggle(fromStart: Boolean, onChange: (Boolean) -> Unit) { + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { + listOf(true, false).forEachIndexed { index, start -> + SegmentedButton( + selected = fromStart == start, + onClick = { onChange(start) }, + shape = SegmentedButtonDefaults.itemShape(index, 2), + label = { Text(stringResource(reminderAnchorLabel(start))) }, + ) + } + } +} + @Composable private fun CustomReminderEditor( amountText: String, onAmountChange: (String) -> Unit, unit: ReminderUnit, onUnitChange: (ReminderUnit) -> Unit, + units: List, + allDay: Boolean, onConfirm: (Int) -> Unit, ) { val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 } - Surface( - color = MaterialTheme.colorScheme.surfaceContainerHigh, - // A Position.Bottom shape: tight top corners meeting the row, full bottom. - shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp), - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { - ReminderUnit.entries.forEachIndexed { index, entry -> - SegmentedButton( - selected = unit == entry, - onClick = { onUnitChange(entry) }, - shape = SegmentedButtonDefaults.itemShape(index, ReminderUnit.entries.size), - label = { Text(stringResource(reminderUnitLabel(entry))) }, - ) - } - } - Row(verticalAlignment = Alignment.CenterVertically) { - DialogAmountField(value = amountText, onValueChange = onAmountChange, placeholder = "10") - Spacer(Modifier.width(16.dp)) - Text( - text = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) } - ?: stringResource(R.string.reminder_custom_amount), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(1f), - ) - Spacer(Modifier.width(16.dp)) - FilledTonalButton( - onClick = { amount?.let { onConfirm(it * unit.minutesFactor) } }, - enabled = amount != null, - ) { - Text(stringResource(R.string.reminder_custom_set)) - } - } - } - } + CustomAmountEditor( + amountText = amountText, + onAmountChange = onAmountChange, + preview = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor, allDay) } + ?: stringResource(R.string.reminder_custom_amount), + setLabel = stringResource(R.string.reminder_custom_set), + confirmEnabled = amount != null, + onConfirm = { amount?.let { onConfirm(it * unit.minutesFactor) } }, + unitLabels = units.map { stringResource(reminderUnitLabel(it)) }, + selectedUnit = units.indexOf(unit), + onUnitChange = { onUnitChange(units[it]) }, + ) } +/** + * The units a custom lead is entered in. An all-day reminder's time of day is + * set on its own, so its lead is whole days: an hour count would read back as a + * time of day. + */ +private fun reminderUnits(allDay: Boolean): List = + if (allDay) listOf(ReminderUnit.Days, ReminderUnit.Weeks) else ReminderUnit.entries + @Composable private fun reminderOverrideLabel(override: ReminderOverride, allDay: Boolean): String = when (override) { ReminderOverride.Inherit -> stringResource(R.string.reminder_use_default) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailScreen.kt index 8a191a4..ca895bd 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailScreen.kt @@ -58,7 +58,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.pluralStringResource import androidx.core.net.toUri -import de.jeanlucmakiola.agendula.data.tasks.TaskReminder +import de.jeanlucmakiola.agendula.domain.TaskReminder import de.jeanlucmakiola.agendula.domain.TaskStatus import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat @@ -66,7 +66,7 @@ import de.jeanlucmakiola.agendula.ui.common.RecurringScopeDialog import de.jeanlucmakiola.agendula.ui.common.TagChip import de.jeanlucmakiola.agendula.ui.common.linkified import de.jeanlucmakiola.agendula.ui.common.recurrenceText -import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel +import de.jeanlucmakiola.agendula.ui.common.taskReminderLabel import de.jeanlucmakiola.floret.locale.currentLocale import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -198,6 +198,7 @@ fun TaskDetailScreen( detail = s.detail, inner = inner, reminders = s.reminders, + allDayMinuteOfDay = s.allDayMinuteOfDay, onToggle = { viewModel.toggleComplete(s.detail.task) }, onToggleSubtask = { viewModel.toggleComplete(it) }, onAddSubtask = { viewModel.addSubtask(it) }, @@ -251,6 +252,7 @@ private fun DetailBody( detail: TaskDetail, inner: androidx.compose.foundation.layout.PaddingValues, reminders: List, + allDayMinuteOfDay: Int, onToggle: () -> Unit, onToggleSubtask: (Task) -> Unit, onAddSubtask: (String) -> Unit, @@ -352,8 +354,12 @@ private fun DetailBody( DetailCard(icon = Icons.Rounded.Notifications, iconContentDescription = stringResource(R.string.edit_reminder_label)) { reminders.forEach { reminder -> Text( - if (reminder.fromStart) stringResource(R.string.detail_reminder_before_start, reminderLeadTimeLabel(reminder.minutesBefore)) - else reminderLeadTimeLabel(reminder.minutesBefore, task.isAllDay), + taskReminderLabel( + reminder, + task.isAllDay, + allDayMinuteOfDay, + anchored = task.start != null && task.due != null, + ), style = MaterialTheme.typography.titleMedium, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailViewModel.kt index acf3a0e..d0f755c 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailViewModel.kt @@ -18,6 +18,11 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import de.jeanlucmakiola.agendula.data.prefs.DEFAULT_ALL_DAY_REMINDER_MINUTE +import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.agendula.domain.taskReminderDisplayOrder import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope import de.jeanlucmakiola.agendula.data.di.ApplicationScope import kotlinx.coroutines.CoroutineScope @@ -30,7 +35,9 @@ sealed interface TaskDetailUiState { data class Content( val detail: TaskDetail, /** The task's own reminders; empty when the list's or app default applies. */ - val reminders: List = emptyList(), + val reminders: List = emptyList(), + /** The time an all-day reminder without one of its own rings at. */ + val allDayMinuteOfDay: Int = DEFAULT_ALL_DAY_REMINDER_MINUTE, ) : TaskDetailUiState } @@ -38,6 +45,7 @@ sealed interface TaskDetailUiState { @HiltViewModel class TaskDetailViewModel @Inject constructor( private val repository: TasksRepository, + private val settingsPrefs: SettingsPrefs, @ApplicationScope private val appScope: CoroutineScope, ) : ViewModel() { @@ -46,14 +54,18 @@ class TaskDetailViewModel @Inject constructor( val state: StateFlow = target.filterNotNull() .flatMapLatest { (id, occurrence) -> + val allDayMinute = settingsPrefs.settings.map { it.allDayReminderMinuteOfDay }.distinctUntilChanged() repository.taskDetail(id, occurrence) - .map { detail -> - if (detail == null) { + .map { detail -> detail?.let { it to repository.remindersFor(it.task.taskId) } } + .combine(allDayMinute) { loaded, minute -> + if (loaded == null) { TaskDetailUiState.NotFound } else { + val (detail, reminders) = loaded TaskDetailUiState.Content( detail = detail, - reminders = repository.remindersFor(detail.task.taskId), + reminders = reminders.sortedWith(taskReminderDisplayOrder(detail.task.isAllDay, minute)), + allDayMinuteOfDay = minute, ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditScreen.kt index 62d6856..13991d4 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditScreen.kt @@ -26,11 +26,14 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import de.jeanlucmakiola.agendula.ui.common.RecurringScopeDialog +import de.jeanlucmakiola.agendula.ui.common.ReminderAnchorToggle import de.jeanlucmakiola.agendula.ui.common.ReminderLeadPicker import de.jeanlucmakiola.agendula.ui.common.icon import de.jeanlucmakiola.agendula.ui.common.labelRes import de.jeanlucmakiola.agendula.ui.common.recurrenceText -import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel +import de.jeanlucmakiola.agendula.ui.common.TimePickerAlert +import de.jeanlucmakiola.agendula.ui.common.formatMinuteOfDay +import de.jeanlucmakiola.agendula.ui.common.taskReminderLabel import de.jeanlucmakiola.floret.components.revealOnFocus import de.jeanlucmakiola.floret.components.FullScreenPicker import de.jeanlucmakiola.floret.components.GroupedListInset @@ -48,6 +51,7 @@ import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -123,6 +127,8 @@ import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskFormError import de.jeanlucmakiola.agendula.domain.TaskFormField import de.jeanlucmakiola.agendula.domain.TaskList +import de.jeanlucmakiola.agendula.domain.TaskReminder +import de.jeanlucmakiola.agendula.domain.DAY_MINUTES import de.jeanlucmakiola.agendula.domain.TaskSection import de.jeanlucmakiola.agendula.domain.TaskSections import de.jeanlucmakiola.floret.components.GroupedRow @@ -263,6 +269,8 @@ private fun EditContent( // the title takes the target list's colour. val accent = selectedList?.let { pastelize(it.color, dark) } ?: MaterialTheme.colorScheme.primary val gap = 12.dp + // A reminder's anchor only says something when the task has both to choose from. + val bothDates = state.start != null && state.due != null var pickerTarget by rememberSaveable { mutableStateOf(null) } var showListPicker by rememberSaveable { mutableStateOf(false) } @@ -559,23 +567,25 @@ private fun EditContent( Spacer(Modifier.height(gap)) // One card per reminder, connected, the last row adding another. val rows = state.reminders.size + 1 - state.reminders.forEachIndexed { index, minutes -> + state.reminders.forEachIndexed { index, reminder -> ReminderItemCard( position = positionOf(index, rows), - title = reminderLeadTimeLabel(minutes, state.isAllDay), - onRemove = { viewModel.removeReminder(minutes) }, + title = taskReminderLabel(reminder, state.isAllDay, state.allDayMinuteOfDay, anchored = bothDates), + onRemove = { viewModel.removeReminder(reminder) }, ) } AddReminderCard( position = positionOf(state.reminders.size, rows), label = stringResource( - if (state.reminders.isEmpty()) R.string.edit_reminder_add_first else R.string.edit_reminder_add, + // Only a set the user has not chosen still falls back to the default. + if (state.reminders.isEmpty() && !state.remindersChosen) R.string.edit_reminder_add_first + else R.string.edit_reminder_add, ), onClick = { showReminderPicker = true }, ) - if (TaskFormError.REMINDER_WITHOUT_DUE in state.errors) { + if (TaskFormError.REMINDER_WITHOUT_DATE in state.errors) { Spacer(Modifier.height(4.dp)) - FieldError(R.string.error_reminder_without_due) + FieldError(R.string.error_reminder_without_date) } } @@ -688,18 +698,73 @@ private fun EditContent( } if (showReminderPicker) { + var fromStart by rememberSaveable { mutableStateOf(state.start != null) } + var minuteOfDay by rememberSaveable { mutableIntStateOf(state.allDayMinuteOfDay) } + var pickingTime by rememberSaveable { mutableStateOf(false) } ReminderLeadPicker( title = stringResource(R.string.edit_reminder_add), selected = ReminderOverride.Inherit, allowInherit = false, allowNone = false, allDay = state.isAllDay, + header = if (bothDates || state.isAllDay) { + { + if (bothDates) { + ReminderAnchorToggle(fromStart = fromStart, onChange = { fromStart = it }) + Spacer(Modifier.height(16.dp)) + } + if (state.isAllDay) { + GroupedRow( + title = stringResource(R.string.reminder_time), + position = Position.Alone, + trailing = { + Text( + formatMinuteOfDay(minuteOfDay, LocalUse24HourFormat.current), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + ) + }, + onClick = { pickingTime = true }, + ) + Spacer(Modifier.height(24.dp)) + } + } + } else { + null + }, onSelect = { choice -> - (choice as? ReminderOverride.Minutes)?.minutes?.firstOrNull()?.let(viewModel::addReminder) + (choice as? ReminderOverride.Minutes)?.minutes?.firstOrNull()?.let { minutes -> + // A task with one date counts from that one, whatever the toggle said. + val anchorStart = if (bothDates) fromStart else state.due == null + viewModel.addReminder( + if (state.isAllDay) { + // The default time is left unnamed, so the reminder follows the setting. + TaskReminder.allDay( + daysBefore = minutes / DAY_MINUTES, + minuteOfDay = minuteOfDay.takeIf { it != state.allDayMinuteOfDay }, + fromStart = anchorStart, + ) + } else { + TaskReminder(minutes, fromStart = anchorStart) + }, + ) + } showReminderPicker = false }, onDismiss = { showReminderPicker = false }, ) + if (pickingTime) { + TimePickerAlert( + initial = LocalTime.of(minuteOfDay / 60, minuteOfDay % 60), + onConfirm = { + val picked = it.hour * 60 + it.minute + // The default time stays unnamed; any other is one an all-day reminder can name. + minuteOfDay = if (picked == state.allDayMinuteOfDay) picked else TaskReminder.allDayMinuteOfDay(picked) + pickingTime = false + }, + onDismiss = { pickingTime = false }, + ) + } } if (showRecurrencePicker) { @@ -819,6 +884,7 @@ private fun InlineField( * affordance appears once a value is set. Tappable values read as links * (primary); an error flips them to the error colour. */ +@OptIn(ExperimentalLayoutApi::class) @Composable private fun ScheduleRow( label: String, @@ -829,43 +895,76 @@ private fun ScheduleRow( isError: Boolean = false, ) { val valueColor = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - Text( - text = label, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(1f), - ) - if (value == null) { + val valueModifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp) + LabelledValueLayout( + label = { Text( - text = stringResource(R.string.edit_set), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp), + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(end = 8.dp), ) - } else { - Text( - text = value.formatDate(allDay), - style = MaterialTheme.typography.titleMedium, - color = valueColor, - modifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp), - ) - if (!allDay) { - Text( - text = value.formatTime(LocalUse24HourFormat.current), - style = MaterialTheme.typography.titleMedium, - color = valueColor, - modifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp), - ) - } - IconButton(onClick = onClear, modifier = Modifier.size(40.dp)) { - Icon( - Icons.Rounded.Clear, - contentDescription = stringResource(R.string.edit_clear), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), - ) + }, + value = { + Row(verticalAlignment = Alignment.CenterVertically) { + if (value == null) { + Text( + text = stringResource(R.string.edit_set), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + modifier = valueModifier, + ) + } else { + FlowRow( + horizontalArrangement = Arrangement.End, + itemVerticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f, fill = false), + ) { + Text( + text = value.formatDate(allDay), + style = MaterialTheme.typography.titleMedium, + color = valueColor, + modifier = valueModifier, + ) + if (!allDay) { + Text( + text = value.formatTime(LocalUse24HourFormat.current), + style = MaterialTheme.typography.titleMedium, + color = valueColor, + modifier = valueModifier, + ) + } + } + IconButton(onClick = onClear, modifier = Modifier.size(40.dp)) { + Icon( + Icons.Rounded.Clear, + contentDescription = stringResource(R.string.edit_clear), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + } } + }, + ) +} + +/** + * [label] at the start and [value] at the end of one line when both fit, else + * [value] on its own line below, still end-aligned — so large font scales wrap + * rather than clip either side (#76). + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun LabelledValueLayout(label: @Composable () -> Unit, value: @Composable () -> Unit) { + FlowRow( + itemVerticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + label() + // Its whole width as its minimum, so the value wraps as one piece. + Box(Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { + Box(Modifier.width(IntrinsicSize.Max)) { value() } } } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditViewModel.kt index 6ad7485..e01423d 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditViewModel.kt @@ -3,6 +3,8 @@ package de.jeanlucmakiola.agendula.ui.edit import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import de.jeanlucmakiola.agendula.data.prefs.DEFAULT_ALL_DAY_REMINDER_MINUTE +import de.jeanlucmakiola.agendula.data.prefs.Settings import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler import de.jeanlucmakiola.agendula.data.tasks.TaskConflictException @@ -16,6 +18,10 @@ import de.jeanlucmakiola.agendula.domain.TaskFormError import de.jeanlucmakiola.agendula.domain.TaskFormField import de.jeanlucmakiola.agendula.domain.allDayInstantOf import de.jeanlucmakiola.agendula.domain.TaskList +import de.jeanlucmakiola.agendula.domain.TaskReminder +import de.jeanlucmakiola.agendula.domain.taskReminderDisplayOrder +import de.jeanlucmakiola.agendula.data.tasks.StoredReminders +import java.time.ZoneId import de.jeanlucmakiola.agendula.domain.populatedFields import de.jeanlucmakiola.agendula.domain.htmlToPlainText import de.jeanlucmakiola.agendula.domain.looksLikeHtml @@ -54,7 +60,13 @@ data class TaskEditUiState( val priority: Priority = Priority.NONE, val parentId: Long? = null, val percentComplete: Int? = null, - val reminders: List = emptyList(), + val reminders: List = emptyList(), + /** Whether the user added or removed a reminder; until then the defaults follow the form. */ + val remindersTouched: Boolean = false, + /** Whether [reminders] are stored as they are — see [TaskForm.remindersChosen]. */ + val remindersChosen: Boolean = false, + /** The time an all-day reminder without one of its own rings at. */ + val allDayMinuteOfDay: Int = DEFAULT_ALL_DAY_REMINDER_MINUTE, val location: String = "", val url: String = "", val tags: List = emptyList(), @@ -124,6 +136,20 @@ class TaskEditViewModel @Inject constructor( /** Sections shown by default (a setting); the rest unfold via [revealField]. */ private var defaultFields: Set = emptySet() + /** The settings as the form was bound, for the defaults an all-day switch swaps in. */ + private var settings: Settings = Settings() + + /** The reminders before the last all-day switch, and what it made of them. */ + private var lastSwitch: Pair, List>? = null + + /** The reminders the task was loaded with, and the dates it had then. */ + private var storedReminders: Set = emptySet() + + /** [storedReminders] as the task's current kind, timed or all-day. */ + private var loadedReminders: List = emptyList() + private var loadedStart: Instant? = null + private var loadedDue: Instant? = null + /** Start a fresh task, optionally pre-selecting a list / parent. */ fun bindNew( presetListId: Long? = null, @@ -138,6 +164,7 @@ class TaskEditViewModel @Inject constructor( baselineLastModified = null viewModelScope.launch { val settings = settingsPrefs.settings.first() + this@TaskEditViewModel.settings = settings defaultFields = settings.defaultEditFields val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() } val preset = presetFilterId?.let { settingsPrefs.savedFilter(it).first() } @@ -157,6 +184,7 @@ class TaskEditViewModel @Inject constructor( loading = false, isNew = true, autofocusTitle = settings.autofocusTitle, + allDayMinuteOfDay = settings.allDayReminderMinuteOfDay, listId = defaultList, // A preset due is all-day, so a default start has to be too, or // today's start would land after a due of today. @@ -174,7 +202,7 @@ class TaskEditViewModel @Inject constructor( parentCandidates = loadParents(defaultList, selfId = null), title = initialTitle.orEmpty(), tags = (listOfNotNull(presetTag) + preset?.tags.orEmpty()).distinct(), - ), + ).withDefaultReminders(), ).also { baseline = it.toForm().copy(title = "") } } } @@ -186,7 +214,9 @@ class TaskEditViewModel @Inject constructor( editingTaskId = taskId editingOccurrence = occurrenceStart viewModelScope.launch { - defaultFields = settingsPrefs.settings.first().defaultEditFields + val settings = settingsPrefs.settings.first() + this@TaskEditViewModel.settings = settings + defaultFields = settings.defaultEditFields val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() } val task = runCatching { repository.taskDetail(taskId, occurrenceStart).first()?.task }.getOrNull() if (task == null) { @@ -194,6 +224,10 @@ class TaskEditViewModel @Inject constructor( return@launch } baselineLastModified = task.lastModified + loadedReminders = repository.remindersFor(taskId).map { it.lead }.distinct() + storedReminders = loadedReminders.toSet() + loadedStart = task.start + loadedDue = task.due _state.value = withFields( TaskEditUiState( loading = false, @@ -208,7 +242,9 @@ class TaskEditViewModel @Inject constructor( priority = task.priority, parentId = task.parentId, percentComplete = task.percentComplete, - reminders = repository.remindersFor(taskId).filter { !it.fromStart }.map { it.minutesBefore }, + reminders = loadedReminders + .sortedWith(taskReminderDisplayOrder(task.isAllDay, settings.allDayReminderMinuteOfDay)), + allDayMinuteOfDay = settings.allDayReminderMinuteOfDay, location = task.location.orEmpty(), url = task.url.orEmpty(), tags = task.tags, @@ -271,35 +307,90 @@ class TaskEditViewModel @Inject constructor( /** Switching list invalidates the parent (it lived in the old list); reload candidates. */ fun onListChange(listId: Long) { if (_state.value.listId == listId) return // re-picking the same list keeps the parent - update { it.copy(listId = listId, parentId = null, errors = emptySet()) } + update { it.copy(listId = listId, parentId = null, errors = emptySet()).withDefaultReminders() } viewModelScope.launch { val candidates = loadParents(listId, selfId = editingTaskId) update { it.copy(parentCandidates = candidates) } } } - fun onStartChange(value: Instant?) = update { it.copy(start = value) } - fun onDueChange(value: Instant?) = update { it.copy(due = value) } + fun onStartChange(value: Instant?) = update { it.copy(start = value).withDefaultReminders() } + fun onDueChange(value: Instant?) = update { it.copy(due = value).withDefaultReminders() } /** * All-day and timed values use different conventions (UTC midnight vs. a real * instant in the device zone), so the switch has to move the timestamps too — * flipping the flag alone makes an all-day task read back as "02:00", which - * looks to the user like the time reset itself. + * looks to the user like the time reset itself. The reminders are made + * the new kind's here, so the editor shows what will be stored; switching + * straight back gives the old ones back. */ fun onAllDayChange(value: Boolean) = update { - it.copy( + if (it.isAllDay == value) return@update it + val switched = it.copy( isAllDay = value, start = it.start?.rebasedForAllDay(value), due = it.due?.rebasedForAllDay(value), ) + if (!it.remindersTouched) { + loadedReminders = carried(loadedReminders, switched) + return@update switched.withDefaultReminders() + } + switched.copy(reminders = carried(it.reminders, switched).sortedForDisplay(switched)) } + + /** [reminders] as [switched]'s kind; straight back after a switch, the ones before it. */ + private fun carried(reminders: List, switched: TaskEditUiState): List { + val previous = lastSwitch + val carried = if (previous != null && previous.second == reminders) { + previous.first + } else { + StoredReminders.switched(reminders, switched.toForm(), settings, nowMillis(), ZoneId.systemDefault()) + } + lastSwitch = reminders to carried + return carried + } + + /** + * The reminders the task will be saved with while the user has not + * touched them: those it was loaded with, plus the defaults of the dates + * it has gained — all of them, for a new task. Shown so they are noted + * before saving rather than added unseen. + */ + private fun TaskEditUiState.withDefaultReminders(): TaskEditUiState { + if (remindersTouched) return this + val reminders = StoredReminders.withDefaults( + toForm().copy(reminders = loadedReminders), + loadedStart, + loadedDue, + settings, + nowMillis(), + ZoneId.systemDefault(), + ).sortedForDisplay(this) + val visible = if (reminders.isEmpty()) visibleFields else visibleFields + TaskFormField.Reminder + return copy( + reminders = reminders, + // A new task's, or defaults added to the ones it had: stored as shown. + remindersChosen = isNew || reminders.toSet() != storedReminders, + visibleFields = visible, + hiddenFields = hiddenOf(visible), + ) + } + + private fun nowMillis(): Long = Clock.System.now().toEpochMilliseconds() fun onPriorityChange(value: Priority) = update { it.copy(priority = value) } fun onPercentChange(value: Int?) = update { it.copy(percentComplete = value?.coerceIn(0, 100)) } fun onParentChange(parentId: Long?) = update { it.copy(parentId = parentId) } - fun addReminder(minutesBeforeDue: Int) = update { - it.copy(reminders = (it.reminders + minutesBeforeDue).distinct().sorted(), errors = emptySet()) + fun addReminder(reminder: TaskReminder) = update { + it.copy( + reminders = (it.reminders + reminder).distinct().sortedForDisplay(it), + remindersTouched = true, + remindersChosen = true, + errors = emptySet(), + ) + } + fun removeReminder(reminder: TaskReminder) = update { + it.copy(reminders = it.reminders - reminder, remindersTouched = true, remindersChosen = true) } - fun removeReminder(minutesBeforeDue: Int) = update { it.copy(reminders = it.reminders - minutesBeforeDue) } fun onLocationChange(value: String) = update { it.copy(location = value) } fun onUrlChange(value: String) = update { it.copy(url = value) } fun addTag(name: String) = update { it.copy(tags = normalizeTags(it.tags + name), tagDraft = "") } @@ -382,6 +473,10 @@ class TaskEditViewModel @Inject constructor( } } +/** The order the editor lists [state]'s reminders in. */ +private fun List.sortedForDisplay(state: TaskEditUiState): List = + sortedWith(taskReminderDisplayOrder(state.isAllDay, state.allDayMinuteOfDay)) + /** Snapshot the editable state as a validated [TaskForm]. */ private fun TaskEditUiState.toForm(): TaskForm = TaskForm( title = title, @@ -394,6 +489,7 @@ private fun TaskEditUiState.toForm(): TaskForm = TaskForm( parentId = parentId, percentComplete = percentComplete, reminders = reminders, + remindersChosen = remindersChosen, location = location.ifBlank { null }, url = url.ifBlank { null }, tags = withDraft(tagDraft), diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/export/ExportScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/export/ExportScreen.kt index 8b342e4..e1557ed 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/export/ExportScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/export/ExportScreen.kt @@ -70,6 +70,7 @@ private const val EXTERNAL_STORAGE = "com.android.externalstorage.documents" @Composable fun ExportScreen( onBack: () -> Unit, + visible: Boolean, modifier: Modifier = Modifier, viewModel: ExportViewModel = hiltViewModel(), ) { @@ -89,6 +90,8 @@ fun ExportScreen( title = stringResource(R.string.settings_export), onBack = onBack, modifier = modifier, + predictiveBack = true, + visible = visible, ) { Text( text = stringResource(R.string.export_hint), diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/imports/ImportScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/imports/ImportScreen.kt index 0818e2f..85bfe03 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/imports/ImportScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/imports/ImportScreen.kt @@ -76,6 +76,8 @@ private val PICK_TYPES = arrayOf("text/calendar", "text/x-vcalendar", "applicati fun ImportScreen( onBack: () -> Unit, modifier: Modifier = Modifier, + predictiveBack: Boolean = false, + visible: Boolean? = null, initialUri: Uri? = null, onOpenStorage: (() -> Unit)? = null, viewModel: ImportViewModel = hiltViewModel(), @@ -105,6 +107,8 @@ fun ImportScreen( title = stringResource(R.string.import_title), onBack = onBack, modifier = modifier, + predictiveBack = predictiveBack, + visible = visible, ) { Text( text = stringResource(R.string.import_hint), diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/licences/LicencesScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/licences/LicencesScreen.kt index 84c86ab..f808271 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/licences/LicencesScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/licences/LicencesScreen.kt @@ -25,12 +25,14 @@ import de.jeanlucmakiola.floret.components.positionOf * source, which is the specific thing §3.2(a) requires us to point at. */ @Composable -internal fun LicencesScreen(onBack: () -> Unit) { +internal fun LicencesScreen(onBack: () -> Unit, visible: Boolean) { val uriHandler = LocalUriHandler.current CollapsingScaffold( title = stringResource(R.string.licences_title), onBack = onBack, + predictiveBack = true, + visible = visible, ) { Text( stringResource(R.string.licences_intro), 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 4bde49d..0365733 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 @@ -3,12 +3,14 @@ package de.jeanlucmakiola.agendula.ui.navigation import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInHorizontally import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +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.Task import kotlin.time.Instant @@ -27,6 +29,7 @@ import de.jeanlucmakiola.agendula.ui.lists.ListsScreen import de.jeanlucmakiola.agendula.ui.settings.SettingsScreen import de.jeanlucmakiola.agendula.ui.tasklist.TaskListScreen import de.jeanlucmakiola.agendula.ui.tasklist.TaskListViewModel +import de.jeanlucmakiola.floret.identity.predictiveBackExit /** * The single in-app back stack: lists → task list → detail / edit. @@ -37,6 +40,7 @@ import de.jeanlucmakiola.agendula.ui.tasklist.TaskListViewModel * `SavedStateHandle`). Screens receive plain navigation lambdas so they stay * unaware of the nav library. */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun AgendulaNavHost( modifier: Modifier = Modifier, @@ -70,19 +74,19 @@ fun AgendulaNavHost( onNavRequestConsumed() } + val slideSpec = MaterialTheme.motionScheme.defaultSpatialSpec() + val overlayExit = predictiveBackExit() + NavHost( navController = nav, startDestination = Dest.LISTS, modifier = modifier, - // Only the screen on top moves; the background is always held static. - // Forward is a plain fade-in over the held screen — calm, not a slide. - // Back is the predictive-back peek: navigation-compose composes the - // screen being revealed underneath and seeks popExit with the gesture, so - // the leaving screen scales down to reveal the (unmoved) previous screen. - enterTransition = { fadeIn() }, + // Calendula's overlay motion. NavHost's own back handler seeks the exit + // over the previous screen, so destinations don't add a preview of their own. + enterTransition = { slideInHorizontally(slideSpec) { it } + fadeIn() }, exitTransition = { ExitTransition.None }, popEnterTransition = { EnterTransition.None }, - popExitTransition = { scaleOut(targetScale = 0.9f) + fadeOut() }, + popExitTransition = { overlayExit }, ) { composable(Dest.LISTS) { ReadableWidth { 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 b4137bc..7c1ce72 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 @@ -34,6 +34,7 @@ internal fun AppearanceScreen( state: SettingsUiState, viewModel: SettingsViewModel, onBack: () -> Unit, + visible: Boolean, ) { val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S var showTheme by remember { mutableStateOf(false) } @@ -42,7 +43,12 @@ internal fun AppearanceScreen( val systemIs24Hour = DateFormat.is24HourFormat(LocalContext.current) val locale = currentLocale() - CollapsingScaffold(title = stringResource(R.string.settings_section_appearance), onBack = onBack) { + CollapsingScaffold( + title = stringResource(R.string.settings_section_appearance), + onBack = onBack, + predictiveBack = true, + visible = visible, + ) { GroupedRow( title = stringResource(R.string.settings_theme), summary = stringResource(themeLabel(state.settings.themeMode)), diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/BackupScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/BackupScreen.kt new file mode 100644 index 0000000..0cd7175 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/BackupScreen.kt @@ -0,0 +1,37 @@ +package de.jeanlucmakiola.agendula.ui.settings + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.floret.components.CollapsingScaffold +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.Position + +/** Backup & restore: the way into export and import, as its own entry (after Calendula). */ +@Composable +internal fun BackupScreen( + onOpenExport: () -> Unit, + onOpenImport: () -> Unit, + onBack: () -> Unit, + visible: Boolean, +) { + CollapsingScaffold( + title = stringResource(R.string.settings_section_backup), + onBack = onBack, + predictiveBack = true, + visible = visible, + ) { + GroupedRow( + title = stringResource(R.string.settings_export), + summary = stringResource(R.string.settings_export_hint), + position = Position.Top, + onClick = onOpenExport, + ) + GroupedRow( + title = stringResource(R.string.import_title), + summary = stringResource(R.string.settings_import_hint), + position = Position.Bottom, + onClick = onOpenImport, + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/RemindersScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/NotificationsScreen.kt similarity index 92% rename from app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/RemindersScreen.kt rename to app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/NotificationsScreen.kt index f852078..8728d8c 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/RemindersScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/NotificationsScreen.kt @@ -41,6 +41,8 @@ import androidx.core.net.toUri import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.agendula.data.prefs.SNOOZE_PRESETS import de.jeanlucmakiola.agendula.data.reminders.TaskNotifier +import de.jeanlucmakiola.agendula.domain.TaskReminderOrder +import de.jeanlucmakiola.agendula.domain.asTaskReminder import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.agendula.ui.common.OnResume import de.jeanlucmakiola.agendula.ui.common.canScheduleExactAlarms @@ -48,7 +50,7 @@ import de.jeanlucmakiola.agendula.ui.common.openExactAlarmSettings import de.jeanlucmakiola.agendula.ui.common.ReminderDefaultPicker import de.jeanlucmakiola.agendula.ui.common.TimePickerAlert import de.jeanlucmakiola.agendula.ui.common.formatMinuteOfDay -import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel +import de.jeanlucmakiola.agendula.ui.common.reminderLabel import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.OptionPicker @@ -57,16 +59,18 @@ import de.jeanlucmakiola.floret.components.SelectedCheck import de.jeanlucmakiola.floret.components.pastelize import de.jeanlucmakiola.floret.identity.collapseExit import de.jeanlucmakiola.floret.identity.expandEnter +import de.jeanlucmakiola.floret.reminders.ReminderLead import de.jeanlucmakiola.floret.reminders.ReminderOverride import de.jeanlucmakiola.floret.reminders.reminderOverrideFor import java.time.LocalTime /** Whether due tasks notify at all, how far ahead, and which lists differ. */ @Composable -internal fun RemindersScreen( +internal fun NotificationsScreen( state: SettingsUiState, viewModel: SettingsViewModel, onBack: () -> Unit, + visible: Boolean, ) { val context = LocalContext.current var notificationsAllowed by remember { mutableStateOf(context.notificationsAllowed()) } @@ -119,7 +123,12 @@ internal fun RemindersScreen( ) val lists = state.lists.filter { it.id > 0L } - CollapsingScaffold(title = stringResource(R.string.settings_section_reminders), onBack = onBack) { + CollapsingScaffold( + title = stringResource(R.string.settings_section_reminders), + onBack = onBack, + predictiveBack = true, + visible = visible, + ) { if (state.settings.remindersEnabled && !notificationsAllowed) { GroupedRow( title = stringResource(R.string.settings_notifications_blocked), @@ -153,13 +162,13 @@ internal fun RemindersScreen( } GroupedRow( title = stringResource(R.string.settings_default_reminder), - summary = reminderChoiceLabel(state.settings.defaultReminderMinutes), + summary = reminderChoiceLabel(state.settings.defaultReminders), position = Position.Middle, onClick = { showOffset = true }, ) GroupedRow( title = stringResource(R.string.settings_default_reminder_allday), - summary = reminderChoiceLabel(state.settings.defaultAllDayReminderMinutes, allDay = true), + summary = reminderChoiceLabel(state.settings.defaultAllDayReminders, allDay = true), position = Position.Middle, onClick = { showAllDay = true }, ) @@ -245,7 +254,7 @@ internal fun RemindersScreen( title = stringResource(R.string.settings_default_reminder), summary = listOverrideSummary( state.settings.perListReminderOverride.reminderOverrideFor(list.id), - state.settings.defaultReminderMinutes, + state.settings.defaultReminders, allDay = false, ), position = Position.Middle, @@ -255,7 +264,7 @@ internal fun RemindersScreen( title = stringResource(R.string.settings_default_reminder_allday), summary = listOverrideSummary( state.settings.perListAllDayReminderOverride.reminderOverrideFor(list.id), - state.settings.defaultAllDayReminderMinutes, + state.settings.defaultAllDayReminders, allDay = true, ), position = Position.Middle, @@ -278,24 +287,24 @@ internal fun RemindersScreen( if (showOffset) { ReminderDefaultPicker( title = stringResource(R.string.settings_default_reminder), - selected = state.settings.defaultReminderMinutes.let { - if (it.isEmpty()) ReminderOverride.None else ReminderOverride.Minutes(it) + selected = state.settings.defaultReminders.let { + if (it.isEmpty()) ReminderOverride.None else ReminderOverride.Minutes.of(it) }, allowInherit = false, - onSelect = { viewModel.setDefaultReminderMinutes((it as? ReminderOverride.Minutes)?.minutes.orEmpty()) }, + onSelect = { viewModel.setDefaultReminders((it as? ReminderOverride.Minutes)?.leads.orEmpty()) }, onDismiss = { showOffset = false }, ) } if (showAllDay) { ReminderDefaultPicker( title = stringResource(R.string.settings_default_reminder_allday), - selected = state.settings.defaultAllDayReminderMinutes.let { - if (it.isEmpty()) ReminderOverride.None else ReminderOverride.Minutes(it) + selected = state.settings.defaultAllDayReminders.let { + if (it.isEmpty()) ReminderOverride.None else ReminderOverride.Minutes.of(it) }, allowInherit = false, allDay = true, leadTimeSummary = allDayFiresAt, - onSelect = { viewModel.setDefaultAllDayReminderMinutes((it as? ReminderOverride.Minutes)?.minutes.orEmpty()) }, + onSelect = { viewModel.setDefaultAllDayReminders((it as? ReminderOverride.Minutes)?.leads.orEmpty()) }, onDismiss = { showAllDay = false }, ) } @@ -350,19 +359,27 @@ private data class OverrideTarget(val listId: Long, val allDay: Boolean) /** Row summary for a list: its override, or the inherited global default. */ @Composable -private fun listOverrideSummary(choice: ReminderOverride, globalDefault: List, allDay: Boolean): String = +private fun listOverrideSummary(choice: ReminderOverride, globalDefault: List, allDay: Boolean): String = when (choice) { ReminderOverride.Inherit -> stringResource(R.string.settings_list_reminder_inherits, reminderChoiceLabel(globalDefault, allDay)) ReminderOverride.None -> stringResource(R.string.reminder_none) - is ReminderOverride.Minutes -> reminderChoiceLabel(choice.minutes, allDay) + is ReminderOverride.Minutes -> reminderChoiceLabel(choice.leads, allDay) } -/** "30 minutes before, At due time", or "No reminder" for an empty set. */ +/** + * "30 minutes before, At due time", or "No reminder" for an empty set. Each + * names its date once any counts from the start. + */ @Composable -private fun reminderChoiceLabel(minutes: List, allDay: Boolean = false): String = - if (minutes.isEmpty()) stringResource(R.string.reminder_none) - else minutes.map { reminderLeadTimeLabel(it, allDay) }.joinToString(", ") +private fun reminderChoiceLabel(leads: List, allDay: Boolean = false): String { + if (leads.isEmpty()) return stringResource(R.string.reminder_none) + val reminders = leads.map { it.asTaskReminder() }.sortedWith(TaskReminderOrder) + val anchored = reminders.any { it.fromStart } + return reminders.map { reminder -> + reminderLabel(reminder.minutesBefore, allDay, reminder.fromStart, anchored) + }.joinToString(", ") +} /** SCHEDULE_EXACT_ALARM is user-granted from API 31; below that exact alarms need no grant. */ private fun showExactAlarmRow(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt index 601b115..35509ff 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt @@ -1,11 +1,8 @@ package de.jeanlucmakiola.agendula.ui.settings -import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer @@ -13,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Backup import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.Code import androidx.compose.material.icons.filled.Favorite @@ -25,6 +23,7 @@ import androidx.compose.material.icons.filled.Translate import androidx.compose.material.icons.filled.Tune import androidx.compose.material.icons.rounded.CloudSync import androidx.compose.material.icons.rounded.Storage +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -37,6 +36,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -50,20 +50,22 @@ import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.components.GroupedListInset import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedSectionHeader +import de.jeanlucmakiola.floret.components.LanguagePickerRow import de.jeanlucmakiola.floret.components.OptionPicker import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.crash.CrashReportDialog import de.jeanlucmakiola.floret.crash.CrashReporter import de.jeanlucmakiola.floret.crash.openIssueTracker import de.jeanlucmakiola.floret.crash.submitCrashReport -import de.jeanlucmakiola.floret.locale.AppLanguage +import de.jeanlucmakiola.floret.identity.predictiveBackExit /** The settings sub-screens reached from the hub's category rows. */ internal enum class SettingsSection { Appearance, TaskForm, - Reminders, + Notifications, Storage, + Backup, Export, Import, Accounts, @@ -72,10 +74,10 @@ internal enum class SettingsSection { Licences, ; - /** Where back goes: Export is opened from Storage, the account screens from Accounts. */ + /** The section that stays composed underneath: Backup under export and import, Accounts under its screens. */ val parent: SettingsSection? get() = when (this) { - Export, Import -> Storage + Export, Import -> Backup AddAccount, Account -> Accounts else -> null } @@ -88,8 +90,9 @@ internal enum class SettingsSection { * A full-screen destination; [onBack] pops it. [initialSection] names a * sub-screen to open on (see [de.jeanlucmakiola.agendula.ui.navigation.Dest.Settings]). * - * Every sub-screen is its own file. This one owns the hub, the section enum and - * the back rules between them, and nothing else. + * Every sub-screen is its own file and handles its own back through the + * predictive-back preview. This one owns the hub, the section enum and the + * routing between them, and nothing else. */ @Composable fun SettingsScreen( @@ -106,10 +109,8 @@ fun SettingsScreen( else SettingsSection.entries.firstOrNull { it.name == initialSection }, ) } - - // Inside a sub-screen, system back (button or gesture) returns to the hub - // rather than popping the whole Settings destination to the lists overview. - BackHandler(enabled = section != null) { section = section?.parent } + // Import's "open storage" comes back to Import rather than the hub. + var storageReturn by rememberSaveable { mutableStateOf(null) } Box( modifier = modifier @@ -118,34 +119,58 @@ fun SettingsScreen( ) { SettingsHub(onBack = onBack, onOpenSection = { section = it }) - SlideInSection(visible = section == SettingsSection.Appearance) { - AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null }) + val appearance = section == SettingsSection.Appearance + SlideInSection(visible = appearance) { + AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = appearance) } - SlideInSection(visible = section == SettingsSection.TaskForm) { - TaskFormScreen(state = state, viewModel = viewModel, onBack = { section = null }) + val taskForm = section == SettingsSection.TaskForm + SlideInSection(visible = taskForm) { + TaskFormScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = taskForm) } - SlideInSection(visible = section == SettingsSection.Reminders) { - RemindersScreen(state = state, viewModel = viewModel, onBack = { section = null }) + val notifications = section == SettingsSection.Notifications + SlideInSection(visible = notifications) { + NotificationsScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = notifications) } - // Storage stays composed under Export, so the deeper screen slides over it. - val storageOpen = section == SettingsSection.Storage || - section?.parent == SettingsSection.Storage - SlideInSection(visible = storageOpen) { + val storage = section == SettingsSection.Storage + SlideInSection(visible = storage) { StorageScreen( viewModel = viewModel, + onBack = { + section = storageReturn + storageReturn = null + }, + visible = storage, + ) + } + // Backup stays composed under export and import, so the deeper screen slides over it. + val backup = section == SettingsSection.Backup || section?.parent == SettingsSection.Backup + SlideInSection(visible = backup) { + BackupScreen( onOpenExport = { section = SettingsSection.Export }, onOpenImport = { section = SettingsSection.Import }, onBack = { section = null }, + visible = backup, ) } - SlideInSection(visible = section == SettingsSection.Export) { - ExportScreen(onBack = { section = SettingsSection.Storage }) + val exportOpen = section == SettingsSection.Export + SlideInSection(visible = exportOpen) { + ExportScreen(onBack = { section = SettingsSection.Backup }, visible = exportOpen) } - SlideInSection(visible = section == SettingsSection.Import) { - ImportScreen(onBack = { section = SettingsSection.Storage }) + val importOpen = section == SettingsSection.Import + SlideInSection(visible = importOpen) { + ImportScreen( + onBack = { section = SettingsSection.Backup }, + onOpenStorage = { + storageReturn = SettingsSection.Import + section = SettingsSection.Storage + }, + predictiveBack = true, + visible = importOpen, + ) } - SlideInSection(visible = section == SettingsSection.Licences) { - LicencesScreen(onBack = { section = null }) + val licences = section == SettingsSection.Licences + SlideInSection(visible = licences) { + LicencesScreen(onBack = { section = null }, visible = licences) } AccountSections( section = section, @@ -155,12 +180,18 @@ fun SettingsScreen( } } +/** + * A sub-screen layered over the hub, with Calendula's overlay motion: it slides + * in on the motion scheme's spring and leaves from its predictive-back preview. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable internal fun SlideInSection(visible: Boolean, content: @Composable () -> Unit) { + val slideSpec = MaterialTheme.motionScheme.defaultSpatialSpec() AnimatedVisibility( visible = visible, - enter = slideInHorizontally { it } + fadeIn(), - exit = slideOutHorizontally { it } + fadeOut(), + enter = slideInHorizontally(slideSpec) { it } + fadeIn(), + exit = predictiveBackExit(), ) { content() } } @@ -208,7 +239,7 @@ private fun SettingsHub( summary = stringResource(R.string.settings_reminders_subtitle), position = Position.Bottom, leading = { CategoryIcon(Icons.Default.Notifications, ChipAccent.Primary) }, - onClick = { onOpenSection(SettingsSection.Reminders) }, + onClick = { onOpenSection(SettingsSection.Notifications) }, ) Spacer(Modifier.height(8.dp)) @@ -225,14 +256,41 @@ private fun SettingsHub( GroupedRow( title = stringResource(R.string.settings_section_storage), summary = stringResource(R.string.settings_storage_subtitle), - position = if (BuildConfig.SYNC_ENABLED) Position.Bottom else Position.Alone, - leading = { CategoryIcon(Icons.Rounded.Storage, ChipAccent.Secondary) }, + position = if (BuildConfig.SYNC_ENABLED) Position.Middle else Position.Top, + leading = { CategoryIcon(Icons.Rounded.Storage, ChipAccent.Primary) }, onClick = { onOpenSection(SettingsSection.Storage) }, ) + GroupedRow( + title = stringResource(R.string.settings_section_backup), + summary = stringResource(R.string.settings_backup_subtitle), + position = Position.Bottom, + leading = { CategoryIcon(Icons.Default.Backup, ChipAccent.Secondary) }, + onClick = { onOpenSection(SettingsSection.Backup) }, + ) Spacer(Modifier.height(8.dp)) GroupedSectionHeader(stringResource(R.string.settings_group_app)) - LanguageRow(position = Position.Top) + LanguagePickerRow( + position = Position.Top, + title = stringResource(R.string.settings_language), + autoLabel = stringResource(R.string.settings_language_auto), + localesConfig = R.xml.locales_config, + predictiveBack = true, + // Invite contributions right where users pick their language. + header = { + val context = LocalContext.current + val translateUrl = stringResource(R.string.about_translate_url) + GroupedRow( + title = stringResource(R.string.settings_translate), + summary = stringResource(R.string.settings_translate_hint), + position = Position.Alone, + leading = { CategoryIcon(Icons.Default.Translate, ChipAccent.Neutral) }, + onClick = { openUrl(context, translateUrl) }, + ) + Spacer(Modifier.height(16.dp)) + }, + leading = { CategoryIcon(Icons.Default.Language, ChipAccent.Secondary) }, + ) ReportProblemRow(position = Position.Bottom) // Source, licence and privacy sit at the bottom as reference material @@ -316,64 +374,6 @@ private fun AboutLinkRow(label: String, url: String, icon: ImageVector, position ) } -/** - * The app-language row. Deliberately not floret-kit's `LanguagePickerRow`: the - * picker it opens carries a "Help translate" header, and inviting contributions - * right where a user goes looking for their language is app-specific framing, - * not a family primitive. Everything else matches that recipe. - */ -@Composable -private fun LanguageRow(position: Position) { - val context = LocalContext.current - val supported = remember { AppLanguage.supportedTags(context, R.xml.locales_config) } - // Setting a locale recreates the activity; mirror the choice locally so the - // row updates instantly even before the recreation lands. - var current by remember { mutableStateOf(AppLanguage.currentTag(supported)) } - var showDialog by remember { mutableStateOf(false) } - - // null = follow the system; the rest are BCP-47 tags from locales_config.xml. - val options = remember(supported) { listOf(null) + supported } - - GroupedRow( - title = stringResource(R.string.settings_language), - summary = languageLabel(current), - position = position, - leading = { CategoryIcon(Icons.Default.Language, ChipAccent.Secondary) }, - onClick = { showDialog = true }, - ) - - if (showDialog) { - OptionPicker( - title = stringResource(R.string.settings_language), - predictiveBack = true, - options = options, - selected = current, - label = { languageLabel(it) }, - onSelect = { - current = it - AppLanguage.apply(it) - }, - onDismiss = { showDialog = false }, - // Invite contributions right where users pick their language. - header = { - val translateUrl = stringResource(R.string.about_translate_url) - GroupedRow( - title = stringResource(R.string.settings_translate), - summary = stringResource(R.string.settings_translate_hint), - position = Position.Alone, - leading = { CategoryIcon(Icons.Default.Translate, ChipAccent.Neutral) }, - onClick = { openUrl(context, translateUrl) }, - ) - Spacer(Modifier.height(16.dp)) - }, - ) - } -} - -@Composable -private fun languageLabel(tag: String?): String = - if (tag == null) stringResource(R.string.settings_language_auto) else AppLanguage.displayName(tag) - /** * Opens the project's issue tracker to report a problem. If a crash report was * captured (and not yet sent), it surfaces that report first via the same diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt index 7171ae1..19e4b9a 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt @@ -22,6 +22,7 @@ import de.jeanlucmakiola.agendula.data.tasks.transfer.TransferCounts import de.jeanlucmakiola.agendula.data.tasks.transfer.TransferResult import de.jeanlucmakiola.agendula.domain.TaskFormField import de.jeanlucmakiola.agendula.domain.TaskList +import de.jeanlucmakiola.floret.reminders.ReminderLead import de.jeanlucmakiola.floret.reminders.ReminderOverride import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow @@ -219,12 +220,12 @@ class SettingsViewModel @Inject constructor( fun setThemeMode(mode: ThemeMode) = viewModelScope.launch { prefs.setThemeMode(mode) } fun setDynamicColor(enabled: Boolean) = viewModelScope.launch { prefs.setDynamicColor(enabled) } fun setDefaultList(id: Long?) = viewModelScope.launch { prefs.setDefaultListId(id) } - fun setDefaultReminderMinutes(minutes: List) = viewModelScope.launch { - prefs.setDefaultReminderMinutes(minutes) + fun setDefaultReminders(leads: List) = viewModelScope.launch { + prefs.setDefaultReminders(leads) resyncReminders() } - fun setDefaultAllDayReminderMinutes(minutes: List) = viewModelScope.launch { - prefs.setDefaultAllDayReminderMinutes(minutes) + fun setDefaultAllDayReminders(leads: List) = viewModelScope.launch { + prefs.setDefaultAllDayReminders(leads) resyncReminders() } fun setAllDayReminderMinuteOfDay(minuteOfDay: Int) = viewModelScope.launch { diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/StorageScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/StorageScreen.kt index 62ebe04..088bea9 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/StorageScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/StorageScreen.kt @@ -55,9 +55,8 @@ import de.jeanlucmakiola.floret.components.SelectedCheck @Composable internal fun StorageScreen( viewModel: SettingsViewModel, - onOpenExport: () -> Unit, - onOpenImport: () -> Unit, onBack: () -> Unit, + visible: Boolean, ) { val context = LocalContext.current val storage by viewModel.storage.collectAsStateWithLifecycle() @@ -85,11 +84,17 @@ internal fun StorageScreen( // permission dialog too, and would wipe the refusal before it is read. OnResume { viewModel.refreshStorage() } - CollapsingScaffold(title = stringResource(R.string.settings_section_storage), onBack = onBack) { + CollapsingScaffold( + title = stringResource(R.string.settings_section_storage), + onBack = onBack, + predictiveBack = true, + visible = visible, + ) { + val canCopy = storage?.canCopyFromExternal == true GroupedRow( title = stringResource(R.string.settings_task_store), summary = storage?.let { storeLabel(it) }, - position = Position.Top, + position = if (canCopy) Position.Top else Position.Alone, // Nothing to pick until the stored mode has landed; opening the picker // on the seeded state would offer the wrong store as the current one. onClick = storage?.let { @@ -99,31 +104,19 @@ internal fun StorageScreen( } }, ) - if (storage?.canCopyFromExternal == true) { + if (canCopy) { GroupedRow( title = stringResource( R.string.settings_copy_from_external, storage?.let { externalTitle(it.external, it.externalLabel) }.orEmpty(), ), summary = stringResource(R.string.settings_copy_from_external_hint), - position = Position.Middle, + position = Position.Bottom, leading = { Icon(Icons.Rounded.MoveDown, contentDescription = null) }, // Inert while a copy is in flight; the receipt below says so. onClick = if (transfer.running) null else viewModel::startCopyFromExternal, ) } - GroupedRow( - title = stringResource(R.string.settings_export), - summary = stringResource(R.string.settings_export_hint), - position = Position.Middle, - onClick = onOpenExport, - ) - GroupedRow( - title = stringResource(R.string.import_title), - summary = stringResource(R.string.settings_import_hint), - position = Position.Bottom, - onClick = onOpenImport, - ) TransferStatus(transfer) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/TaskFormScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/TaskFormScreen.kt index cd1c891..2f4ddbb 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/TaskFormScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/TaskFormScreen.kt @@ -42,10 +42,16 @@ internal fun TaskFormScreen( state: SettingsUiState, viewModel: SettingsViewModel, onBack: () -> Unit, + visible: Boolean, ) { var showDefaultList by remember { mutableStateOf(false) } - CollapsingScaffold(title = stringResource(R.string.settings_section_task_form), onBack = onBack) { + CollapsingScaffold( + title = stringResource(R.string.settings_section_task_form), + onBack = onBack, + predictiveBack = true, + visible = visible, + ) { SettingsHint(stringResource(R.string.settings_form_fields_hint)) Spacer(Modifier.height(8.dp)) val fields = TaskFormField.entries 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 de873dc..c4eedcf 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidget.kt @@ -183,12 +183,15 @@ private fun TaskWidgetBody( modifier = GlanceModifier.fillMaxSize().padding(horizontal = 8.dp, vertical = 4.dp), ) { TaskWidgetHeader(title, filter, ready?.singleListId) - Spacer(GlanceModifier.height(2.dp)) when (data) { null -> Unit - TaskWidgetData.Unavailable -> WidgetMessage(context.getString(R.string.widget_unavailable)) + TaskWidgetData.Unavailable -> { + Spacer(GlanceModifier.height(R.dimen.widget_tasks_header_gap_tight)) + WidgetMessage(context.getString(R.string.widget_unavailable)) + } is TaskWidgetData.Ready -> if (data.rows.isEmpty()) { + 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 allSections = widgetSections( @@ -213,6 +216,12 @@ private fun TaskWidgetBody( val redundantHeader = onlyHeader != null && redundantDueKind != null && onlyHeader.key == "due:${redundantDueKind.name}" val sections = if (redundantHeader) allSections.filterNot { it is WidgetSection.Header } else allSections + val leadingGap = if (sections.firstOrNull() is WidgetSection.TaskItem) { + R.dimen.widget_tasks_header_gap + } else { + R.dimen.widget_tasks_header_gap_tight + } + Spacer(GlanceModifier.height(leadingGap)) LazyColumn(modifier = GlanceModifier.fillMaxSize()) { items(sections.size) { index -> when (val section = sections[index]) { diff --git a/app/src/main/res/layout/widget_preview_tasks.xml b/app/src/main/res/layout/widget_preview_tasks.xml index 2eb60fc..be42c33 100644 --- a/app/src/main/res/layout/widget_preview_tasks.xml +++ b/app/src/main/res/layout/widget_preview_tasks.xml @@ -39,7 +39,7 @@ جميع المهام في السلسلة أدخل عنوانًا. اختر قائمة. - عيِّن موعد استحقاق لاستخدام التذكير. تحتاج مهمة متكررة لموعد بدء أو استحقاق. لا يتكرر إزالة الوسم %1$s لم يتم حفظ تغييراتك لهذه المهمة. حفظ التغييرات لمهمة متكررة أضف تذكيرًا (بخلاف ذلك الافتراضي ينطبق) - %1$s (قبل البدء) %1$d / %2$d سيتم حذف هذه المهمة. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 767d403..f1f1cf2 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -78,7 +78,6 @@ Gebe einen Titel ein. Wähle eine Liste. Fälligkeit darf nicht vor dem Start liegen. - Setze ein Fälligkeits-Datum für Erinnerungen. Listen Neue Aufgabe Deine Aufgaben konnten nicht gelesen werden. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 15dd7bb..390cee9 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -108,7 +108,6 @@ Veuillez saisir un titre. Sélectionnez une liste. La date d\'échéance ne peut pas être antérieure au début. - Fixez une date d\'échéance pour pourvoir définir un rappel. Listes Nouvelle tâche Impossible de lire vos tâches. diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 080fdcd..044a1c3 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -79,7 +79,6 @@ Insira um título. Escolha uma lista. Prazo não pode ser antes do início. - Ajuste um prazo para usar um lembrete. Listas Nova tarefa Não foi possível ler suas tarefas. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 06da004..3a5dfb7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -122,8 +122,8 @@ Days Weeks Per-list reminders - Override the default reminder for individual lists - Notifications + Override the default per list — separately for timed and all-day tasks. A list can keep the default, drop it, or set its own. + Sound & vibration Sound, vibration and importance for this list’s reminders Default (%1$s) @@ -181,7 +181,7 @@ Enter a title. Choose a list. Due can\'t be before the start. - Set a due date to use a reminder. + Set a start or due date to use a reminder. A repeating task needs a start or due date. Repeat Does not repeat @@ -199,7 +199,15 @@ Add reminder Add a reminder (otherwise the default applies) Remove reminder - %1$s (before start) + At start time + At due time + On the start date + On the due date + + %1$s at %2$s + From start + From due date + Time %1$d / %2$d %1$d/%2$d Delete task? @@ -359,7 +367,7 @@ Keep a copy - Without an account, your lists live only on this device. Save them as iCalendar files any other task or calendar app can read, any time from Settings → Storage → Export. + Without an account, your lists live only on this device. Save them as iCalendar files any other task or calendar app can read, any time from Settings → Backup & restore → Export. Continue @@ -394,10 +402,10 @@ About Appearance - Theme, colour and time format - Task form - Default fields, list and subtasks - Notifications for due tasks + Theme, colours, time format + New task form + Default fields and behaviour + Reminders and delivery Fields shown by default on a new task. The rest sit behind \"More fields\". Available on Android 12 and later Focus title on new task @@ -442,13 +450,13 @@ Dark Dynamic colour Use colours from your wallpaper - Reminders - Due reminders + Notifications + Task reminders Notify me when a task is due Notifications are off Reminders can\u2019t reach you. Tap to allow notifications for Agendula. - When to remind - Time for all-day reminders + Default reminder + All-day reminder time All-day tasks count back from %1$s All-day tasks Notifies at %1$s @@ -472,9 +480,11 @@ Storage - Where tasks are kept, and export + Where your tasks are kept + Backup & restore + Export and import Task store - Each store keeps its own tasks. Switching does not move them across — copy them over first, or export them. + Each store keeps its own tasks. Switching does not move them across — copy them over first, or export them from Backup & restore. On this device Agendula\'s own storage. Nothing else to install. Another task app @@ -594,6 +604,38 @@ %1$d week before %1$d weeks before + + %1$d minute before the start + %1$d minutes before the start + + + %1$d hour before the start + %1$d hours before the start + + + %1$d day before the start + %1$d days before the start + + + %1$d week before the start + %1$d weeks before the start + + + %1$d minute before the due date + %1$d minutes before the due date + + + %1$d hour before the due date + %1$d hours before the due date + + + %1$d day before the due date + %1$d days before the due date + + + %1$d week before the due date + %1$d weeks before the due date + Accounts @@ -676,6 +718,7 @@ Agendula will keep these lists in sync from now on. You can change what syncs in Settings. Done Where are your tasks? + Agendula couldn\u2019t find the server from your email address. Enter the CalDAV server address your provider gives for calendars instead. Enter the address of your CalDAV server. An email address works too, if the server publishes itself under its domain. Sign in Use an app password if your provider offers one \u2014 it can be revoked without changing your account password. @@ -854,4 +897,10 @@ Lists to show Week A seven-day strip of task counts + + read Agendula tasks + Allows the app to read your task lists and tasks stored in Agendula, including their reminders and tags. + + add, change and delete Agendula tasks + Allows the app to add, change, complete and delete tasks stored in Agendula, including their reminders and tags. It cannot change or delete task lists. diff --git a/app/src/main/res/values/widget_dimens.xml b/app/src/main/res/values/widget_dimens.xml new file mode 100644 index 0000000..fad4897 --- /dev/null +++ b/app/src/main/res/values/widget_dimens.xml @@ -0,0 +1,7 @@ + + + + 8dp + + 2dp + diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/reminders/ReminderPlannerTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/reminders/ReminderPlannerTest.kt index 7ddf389..9d9ad13 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/data/reminders/ReminderPlannerTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/reminders/ReminderPlannerTest.kt @@ -2,9 +2,11 @@ package de.jeanlucmakiola.agendula.data.reminders import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.agendula.data.prefs.Settings -import de.jeanlucmakiola.agendula.data.tasks.TaskReminder +import de.jeanlucmakiola.agendula.domain.TaskReminder import de.jeanlucmakiola.agendula.domain.allDayInstantOf import de.jeanlucmakiola.agendula.domain.testTask +import de.jeanlucmakiola.agendula.domain.asReminderLead +import de.jeanlucmakiola.floret.reminders.asReminderLeads import org.junit.jupiter.api.Test import java.time.LocalDate import java.time.LocalDateTime @@ -38,6 +40,28 @@ class ReminderPlannerTest { assertThat(trigger).isEqualTo(millis(date.minusDays(1).atTime(8, 30), berlin)) } + @Test + fun `a default from before the task existed does not ring`() { + val created = Instant.fromEpochMilliseconds(millis(date.atTime(14, 0), berlin)) + val task = testTask(id = 1, due = allDayInstantOf(date), isAllDay = true, created = created) + val plan = ReminderPlanner.plan( + tasks = listOf(task), + perTask = emptyMap(), + settings = Settings(allDayReminderMinuteOfDay = 9 * 60), + now = created.toEpochMilliseconds(), + zone = berlin, + ) + assertThat(plan).isEmpty() + val own = ReminderPlanner.plan( + tasks = listOf(task), + perTask = mapOf(1L to listOf(TaskReminder(0))), + settings = Settings(allDayReminderMinuteOfDay = 9 * 60), + now = created.toEpochMilliseconds(), + zone = berlin, + ) + assertThat(own).hasSize(1) + } + @Test fun `a timed reminder counts back from the instant itself`() { val due = Instant.fromEpochMilliseconds(1_000_000_000L) @@ -89,7 +113,7 @@ class ReminderPlannerTest { fun plan(perTask: Map>) = ReminderPlanner.plan( tasks = listOf(testTask(id = 4, due = due)), perTask = perTask, - settings = Settings(defaultReminderMinutes = listOf(15)), + settings = Settings(defaultReminders = listOf(15).asReminderLeads()), now = now, zone = berlin, ) @@ -120,8 +144,8 @@ class ReminderPlannerTest { tasks = listOf(task), perTask = emptyMap(), settings = Settings( - defaultReminderMinutes = listOf(5), - defaultAllDayReminderMinutes = listOf(0, 24 * 60), + defaultReminders = listOf(5).asReminderLeads(), + defaultAllDayReminders = listOf(0, 24 * 60).asReminderLeads(), ), now = millis(date.minusDays(2).atTime(12, 0), berlin), zone = berlin, @@ -205,7 +229,7 @@ class ReminderPlannerTest { val plan = ReminderPlanner.plan( tasks = listOf(task), perTask = mapOf(4L to listOf(TaskReminder(0), TaskReminder(60))), - settings = Settings(defaultReminderMinutes = listOf(15)), + settings = Settings(defaultReminders = listOf(15).asReminderLeads()), now = millis(date.atTime(0, 0), berlin), zone = berlin, ) @@ -222,7 +246,7 @@ class ReminderPlannerTest { val plan = ReminderPlanner.plan( tasks = listOf(task), perTask = emptyMap(), - settings = Settings(defaultReminderMinutes = listOf(5, 30)), + settings = Settings(defaultReminders = listOf(5, 30).asReminderLeads()), now = millis(date.atTime(0, 0), berlin), zone = berlin, ) @@ -239,4 +263,65 @@ class ReminderPlannerTest { val plan = ReminderPlanner.plan(listOf(task), emptyMap(), Settings(), millis(date.atTime(0, 0), berlin), berlin) assertThat(plan.single().occurrenceStart).isEqualTo(due.toEpochMilliseconds()) } + + @Test + fun `an all-day reminder with a time of its own rings at it`() { + val nine = TaskReminder.allDay(daysBefore = 0, minuteOfDay = 9 * 60) + val fivePmBefore = TaskReminder.allDay(daysBefore = 1, minuteOfDay = 17 * 60) + assertThat(ReminderPlanner.triggerAt(allDayInstantOf(date), true, nine.minutesBefore, 7 * 60, berlin)) + .isEqualTo(millis(date.atTime(9, 0), berlin)) + assertThat(ReminderPlanner.triggerAt(allDayInstantOf(date), true, fivePmBefore.minutesBefore, 7 * 60, berlin)) + .isEqualTo(millis(date.minusDays(1).atTime(17, 0), berlin)) + } + + @Test + fun `several times on one all-day task each ring`() { + val task = testTask(id = 6, due = allDayInstantOf(date)).copy(isAllDay = true) + val reminders = listOf(TaskReminder.allDay(0, 9 * 60), TaskReminder.allDay(0, 17 * 60)) + val plan = ReminderPlanner.plan(listOf(task), mapOf(6L to reminders), Settings(), millis(date.atTime(0, 0), berlin), berlin) + assertThat(plan).containsExactly( + ScheduledReminder(6, millis(date.atTime(9, 0), berlin)), + ScheduledReminder(6, millis(date.atTime(17, 0), berlin)), + ) + } + + @Test + fun `a default counts only from a date the task has`() { + val start = Instant.fromEpochMilliseconds(millis(date.atTime(9, 0), berlin)) + val due = Instant.fromEpochMilliseconds(millis(date.atTime(18, 0), berlin)) + val both = testTask(id = 7, due = due).copy(start = start) + val dueOnly = testTask(id = 8, due = due) + val startOnly = testTask(id = 9).copy(start = start) + val defaults = listOf(TaskReminder(60, fromStart = true), TaskReminder(0)).map { it.asReminderLead() } + val now = millis(date.atTime(0, 0), berlin) + + val plan = ReminderPlanner.plan( + listOf(both, dueOnly, startOnly), + emptyMap(), + Settings(defaultReminders = defaults), + now, + berlin, + ) + + assertThat(plan).containsExactly( + ScheduledReminder(7, millis(date.atTime(8, 0), berlin)), + ScheduledReminder(7, due.toEpochMilliseconds()), + ScheduledReminder(8, due.toEpochMilliseconds()), + ScheduledReminder(9, millis(date.atTime(8, 0), berlin)), + ) + } + + @Test + fun `a task's own reminder counts from the other date when its own is missing`() { + val start = Instant.fromEpochMilliseconds(millis(date.atTime(9, 0), berlin)) + val task = testTask(id = 10).copy(start = start) + val plan = ReminderPlanner.plan( + listOf(task), + mapOf(10L to listOf(TaskReminder(15))), + Settings(), + millis(date.atTime(0, 0), berlin), + berlin, + ) + assertThat(plan).containsExactly(ScheduledReminder(10, millis(date.atTime(8, 45), berlin))) + } } diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/StoredRemindersTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/StoredRemindersTest.kt new file mode 100644 index 0000000..ed6392c --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/StoredRemindersTest.kt @@ -0,0 +1,116 @@ +package de.jeanlucmakiola.agendula.data.tasks + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.agendula.data.prefs.Settings +import de.jeanlucmakiola.agendula.domain.TaskForm +import de.jeanlucmakiola.agendula.domain.TaskReminder +import de.jeanlucmakiola.agendula.domain.allDayInstantOf +import de.jeanlucmakiola.agendula.domain.testTask +import de.jeanlucmakiola.agendula.domain.asReminderLead +import de.jeanlucmakiola.floret.reminders.asReminderLeads +import org.junit.jupiter.api.Test +import java.time.LocalDate +import java.time.ZoneId +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Instant + +class StoredRemindersTest { + + private val zone = ZoneId.of("Europe/Berlin") + private val now = Instant.parse("2026-10-01T10:00:00Z") + private val dayBeforeStart = TaskReminder(1_440, fromStart = true) + private val dayBeforeDue = TaskReminder(1_440) + private val hourBeforeDue = TaskReminder(60) + private val settings = Settings( + defaultReminders = listOf(dayBeforeStart, dayBeforeDue, hourBeforeDue).map { it.asReminderLead() }, + ) + + private fun stored(form: TaskForm, current: de.jeanlucmakiola.agendula.domain.Task? = null, with: Settings = settings) = + StoredReminders.of(form, current, with, now.toEpochMilliseconds(), zone) + + @Test + fun `a new task takes the defaults for the dates it has`() { + val form = TaskForm(title = "t", listId = 1, start = now + 2.days, due = now + 5.days) + assertThat(stored(form)).containsExactly(dayBeforeStart, dayBeforeDue, hourBeforeDue) + assertThat(stored(form.copy(start = null))).containsExactly(dayBeforeDue, hourBeforeDue) + assertThat(stored(form.copy(due = null))).containsExactly(dayBeforeStart) + } + + @Test + fun `a default already past when the task gets it is left out`() { + val form = TaskForm(title = "t", listId = 1, start = now, due = now + 5.days) + assertThat(stored(form)).containsExactly(dayBeforeDue, hourBeforeDue) + } + + @Test + fun `a recurring task keeps it for the occurrences to come`() { + val form = TaskForm(title = "t", listId = 1, start = now, due = now + 5.days, rrule = "FREQ=DAILY") + assertThat(stored(form)).containsExactly(dayBeforeStart, dayBeforeDue, hourBeforeDue) + } + + private fun switched(reminders: List, form: TaskForm, with: Settings = settings) = + StoredReminders.switched(reminders, form, with, now.toEpochMilliseconds(), zone) + + @Test + fun `a date the task gains brings its defaults`() { + val current = testTask(start = now + 2.days) + val form = TaskForm(title = "t", listId = 1, start = now + 2.days, due = now + 5.days, reminders = listOf(dayBeforeStart)) + assertThat(stored(form, current)).containsExactly(dayBeforeStart, dayBeforeDue, hourBeforeDue) + val due = testTask(due = now + 5.days) + assertThat(stored(form.copy(reminders = listOf(hourBeforeDue)), due)).containsExactly(hourBeforeDue, dayBeforeStart) + } + + @Test + fun `a gained date the form already counts from keeps the form`() { + val current = testTask(start = now + 2.days) + val ownDue = TaskReminder(30) + val form = TaskForm(title = "t", listId = 1, start = now + 2.days, due = now + 5.days, reminders = listOf(ownDue)) + assertThat(stored(form, current)).containsExactly(ownDue) + } + + @Test + fun `reminders the editor chose are stored as they are`() { + val form = TaskForm(title = "t", listId = 1, start = now + 2.days, due = now + 5.days, remindersChosen = true) + assertThat(stored(form)).isEmpty() + assertThat(stored(form.copy(reminders = listOf(hourBeforeDue)), testTask(due = now + 5.days))) + .containsExactly(hourBeforeDue) + } + + @Test + fun `an edit that keeps its dates keeps the form`() { + val current = testTask(due = now + 5.days) + val form = TaskForm(title = "t", listId = 1, due = now + 6.days) + assertThat(stored(form, current)).isEmpty() + } + + @Test + fun `a default from before anchors existed swaps when the task turns all-day`() { + val legacy = Settings(defaultReminders = listOf(15).asReminderLeads(), defaultAllDayReminders = listOf(0).asReminderLeads()) + val due = allDayInstantOf(LocalDate.of(2026, 10, 3)) + val form = TaskForm(title = "t", listId = 1, due = due, isAllDay = true) + assertThat(switched(listOf(TaskReminder(15)), form, legacy)).containsExactly(TaskReminder(0)) + } + + @Test + fun `only the whole default swaps`() { + val due = allDayInstantOf(LocalDate.of(2026, 10, 3)) + val form = TaskForm(title = "t", listId = 1, due = due, isAllDay = true) + assertThat(switched(listOf(dayBeforeDue), form)).containsExactly(dayBeforeDue) + } + + @Test + fun `a reminder of its own turns into whole days on an all-day task`() { + val due = allDayInstantOf(LocalDate.of(2026, 10, 3)) + val form = TaskForm(title = "t", listId = 1, due = due, isAllDay = true) + assertThat(switched(listOf(TaskReminder(20 * 60)), form)).containsExactly(TaskReminder(1_440)) + } + + @Test + fun `a time of day is dropped when the task turns timed`() { + val form = TaskForm(title = "t", listId = 1, due = now + 2.days + 3.hours) + val ownTime = TaskReminder.allDay(0, 17 * 60) + assertThat(switched(listOf(ownTime, TaskReminder(2_880)), form)).containsExactly(TaskReminder(2_880)) + assertThat(switched(listOf(ownTime), form)).containsExactly(dayBeforeDue, hourBeforeDue) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ical/AlarmCodecTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ical/AlarmCodecTest.kt index b324d89..7867906 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ical/AlarmCodecTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ical/AlarmCodecTest.kt @@ -131,17 +131,23 @@ class AlarmCodecTest { inner class AllDay { @Test - fun `a relative trigger rounds to whole days`() { + fun `a relative trigger counts back from the date's midnight`() { assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER;RELATED=END:-P1D"), allDay)) .isEqualTo(lead(24 * 60)) + // 09:00 the day before. assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER;RELATED=END:-PT15H"), allDay)) - .isEqualTo(lead(24 * 60)) + .isEqualTo(lead(15 * 60)) } @Test - fun `a time on the day itself is "on the day", not an after-due alarm`() { + fun `a time on the day itself is a time of day, not an after-due alarm`() { assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER;RELATED=END:PT9H"), allDay)) - .isEqualTo(lead(0)) + .isEqualTo(lead(-9 * 60)) + } + + @Test + fun `a trigger after the day is over is not claimed`() { + assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER;RELATED=END:PT24H"), allDay)).isNull() } @Test @@ -158,6 +164,14 @@ class AlarmCodecTest { val onTheDay = row.copy(minutesBefore = 0) assertThat(AlarmCodec.toVAlarm(onTheDay, allDay, due)?.property("TRIGGER")?.value).isEqualTo("PT0S") } + + @Test + fun `an authored all-day alarm with a time of its own is written from midnight`() { + val nine = TaskAlarmEntity(taskId = 1, minutesBefore = -9 * 60, uid = "a") + assertThat(AlarmCodec.toVAlarm(nine, allDay, due)?.property("TRIGGER")?.value).isEqualTo("PT9H") + val fivePmBefore = nine.copy(minutesBefore = 7 * 60) + assertThat(AlarmCodec.toVAlarm(fivePmBefore, allDay, due)?.property("TRIGGER")?.value).isEqualTo("-PT7H") + } } @Nested @@ -216,11 +230,41 @@ class AlarmCodecTest { assertThat(trigger?.value).isEqualTo("-PT10M") } + @Test + fun `a before-due row on a task without a due goes out before start`() { + val row = TaskAlarmEntity(taskId = 1, minutesBefore = 10, uid = "a") + val trigger = AlarmCodec.toVAlarm(row, withStart.copy(due = null), earlier)?.property("TRIGGER") + assertThat(trigger?.param("RELATED")).isEqualTo("START") + assertThat(trigger?.value).isEqualTo("-PT10M") + } + + @Test + fun `an all-day row kept in whole days by an earlier build keeps the original's time`() { + val row = TaskAlarmEntity( + taskId = 1, + minutesBefore = 0, + uid = "a", + raw = "BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER;RELATED=END:PT9H\r\nEND:VALARM\r\n", + ) + assertThat(AlarmCodec.toVAlarm(row, allDay, earlier)?.property("TRIGGER")?.value).isEqualTo("PT9H") + } + @Test fun `the wire lead is what the row reads back as`() { - val fifteen = TaskAlarmEntity(taskId = 1, minutesBefore = 15, uid = "a") - assertThat(AlarmCodec.wireLead(fifteen, allDay)).isEqualTo(lead(0)) - assertThat(AlarmCodec.wireLead(fifteen, timed)).isEqualTo(lead(15)) + val afterDue = TaskAlarmEntity(taskId = 1, minutesBefore = -15, uid = "a") + assertThat(AlarmCodec.wireLead(afterDue, timed)).isEqualTo(lead(0)) + assertThat(AlarmCodec.wireLead(afterDue, allDay)).isEqualTo(lead(-15)) + } + + @Test + fun `a row rounded by an earlier build does not claim the original's time as its own`() { + val row = TaskAlarmEntity( + taskId = 1, + minutesBefore = 0, + uid = "a", + raw = "BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER;RELATED=END:PT9H\r\nEND:VALARM\r\n", + ) + assertThat(AlarmCodec.wireLead(row, allDay)).isEqualTo(lead(0)) } } diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderIdsTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderIdsTest.kt new file mode 100644 index 0000000..6f9b892 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderIdsTest.kt @@ -0,0 +1,96 @@ +package de.jeanlucmakiola.agendula.data.tasks.provider + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import kotlin.time.Instant + +class ProviderIdsTest { + + @Test + fun `an instance id gives back its series`() { + val id = ProviderIds.instance(1234, Instant.parse("2026-10-15T09:00:00Z")) + + assertThat(ProviderIds.instanceSeries(id)).isEqualTo(1234L) + assertThat(ProviderIds.instanceSeconds(id)).isEqualTo(Instant.parse("2026-10-15T09:00:00Z").epochSeconds) + assertThat(id).isGreaterThan(0L) + } + + @Test + fun `a plain task's instance has no time`() { + assertThat(ProviderIds.instanceSeconds(ProviderIds.instance(7, null))).isEqualTo(0L) + } + + @Test + fun `occurrences of one series and of different series never share an id`() { + val at = Instant.parse("2026-10-15T09:00:00Z") + + val ids = listOf( + ProviderIds.instance(1, at), + ProviderIds.instance(1, Instant.fromEpochSeconds(at.epochSeconds + 1)), + ProviderIds.instance(2, at), + ProviderIds.instance(1, null), + ) + + assertThat(ids).containsNoDuplicates() + } + + @Test + fun `alarm ids are even and tag ids odd`() { + val alarm = ProviderIds.alarm(42) + val tags = ProviderIds.categories(42, listOf("a", "b")) + + assertThat(ProviderIds.isAlarm(alarm)).isTrue() + assertThat(ProviderIds.alarmRow(alarm)).isEqualTo(42L) + tags.values.forEach { + assertThat(ProviderIds.isAlarm(it)).isFalse() + assertThat(ProviderIds.categoryTask(it)).isEqualTo(42L) + } + } + + @Test + fun `a tag's id depends on its task and spelling only`() { + val alone = ProviderIds.categories(5, listOf("home")) + val among = ProviderIds.categories(5, listOf("work", "home", "errand")) + + assertThat(among["home"]).isEqualTo(alone["home"]) + assertThat(ProviderIds.categories(6, listOf("home"))["home"]).isNotEqualTo(alone["home"]) + } + + @Test + fun `two spellings with one hash still get two ids`() { + // "Aa" and "BB" share String.hashCode(). + val ids = ProviderIds.categories(5, listOf("Aa", "BB")) + + assertThat(ids.values).containsNoDuplicates() + assertThat(ids.values.map(ProviderIds::categoryTask)).containsExactly(5L, 5L) + } + + @Test + fun `colliding tags keep their ids whatever their order`() { + val hash = { _: String -> 7L } + + assertThat(ProviderIds.categories(5, listOf("b", "a"), hash)) + .isEqualTo(ProviderIds.categories(5, listOf("a", "b"), hash)) + } + + @Test + fun `colliding tags stay inside their task's bits`() { + val ids = ProviderIds.categories(5, listOf("a", "b", "c"), hash = { 0x7FFFFFFFL }) + + assertThat(ids.values).containsNoDuplicates() + ids.values.forEach { + assertThat(ProviderIds.categoryTask(it)).isEqualTo(5L) + assertThat(ProviderIds.isAlarm(it)).isFalse() + } + } + + @Test + fun `tags of neighbouring tasks never share an id`() { + val names = (1..200).map { "tag$it" } + + val ids = (1L..50L).flatMap { ProviderIds.categories(it, names).values } + + assertThat(ids).containsNoDuplicates() + } +} + diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderSnapshotTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderSnapshotTest.kt new file mode 100644 index 0000000..bc2eeb8 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderSnapshotTest.kt @@ -0,0 +1,371 @@ +package de.jeanlucmakiola.agendula.data.tasks.provider + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.agendula.data.tasks.TasksContract +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Alarm +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Category +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks +import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference +import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity +import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity +import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity +import de.jeanlucmakiola.agendula.data.tasks.room.TaskListRow +import de.jeanlucmakiola.agendula.domain.TaskStatus +import org.junit.jupiter.api.Test +import java.time.ZoneId +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Instant + +class ProviderSnapshotTest { + + private val now = Instant.parse("2026-10-10T08:00:00Z") + private val local = TaskListRow(TaskListEntity(id = 1, name = "Home", color = 0xFF00FF00.toInt()), null) + private val synced = TaskListRow(TaskListEntity(id = 2, name = "Work", color = 0, accountId = 5), "Nextcloud") + + private fun build(tasks: List, alarms: List = emptyList()) = + ProviderSnapshot.build( + lists = listOf(local, synced), + tasks = tasks, + alarms = alarms, + accountType = "caldav-type", + now = now, + zone = ZoneId.of("Europe/Berlin"), + ) + + @Test + fun `lists report the local account for device-only lists and ours for synced ones`() { + val rows = build(emptyList()).rows.getValue(ProviderTable.LISTS) + + assertThat(rows.map { it[Lists.ACCOUNT_TYPE] }) + .containsExactly(TasksContract.LOCAL_ACCOUNT_TYPE, "caldav-type").inOrder() + assertThat(rows.map { it[Lists.ACCOUNT_NAME] }) + .containsExactly(TasksContract.LOCAL_ACCOUNT_NAME, "Nextcloud").inOrder() + } + + @Test + fun `a plain task is one task row and one instance`() { + val due = now + 1.days + val task = TaskEntity(id = 7, listId = 1, uid = "u7", title = "Milk", due = due, priority = 3) + + val snapshot = build(listOf(task)) + val row = snapshot.rows.getValue(ProviderTable.TASKS).single() + val instance = snapshot.rows.getValue(ProviderTable.INSTANCES).single() + + assertThat(row[Tasks.ID]).isEqualTo(7L) + assertThat(row[Tasks.PRIORITY]).isEqualTo(3) + assertThat(row[Tasks.LIST_NAME]).isEqualTo("Home") + assertThat(row[Tasks.IS_CLOSED]).isEqualTo(0) + assertThat(row[Tasks.IS_NEW]).isEqualTo(1) + assertThat(instance[Instances.TASK_ID]).isEqualTo(7L) + assertThat(instance[Instances.INSTANCE_DUE]).isEqualTo(due.toEpochMilliseconds()) + assertThat(instance[Instances.IS_RECURRING]).isEqualTo(0) + assertThat(instance[Instances.DISTANCE_FROM_CURRENT]).isEqualTo(0) + } + + @Test + fun `a completed task is closed`() { + val task = TaskEntity(id = 1, listId = 1, uid = "u", status = TaskStatus.COMPLETED, completedAt = now) + + val row = build(listOf(task)).rows.getValue(ProviderTable.TASKS).single() + + assertThat(row[Tasks.IS_CLOSED]).isEqualTo(1) + assertThat(row[Tasks.STATUS]).isEqualTo(TasksContract.STATUS_COMPLETED) + assertThat(row[Tasks.COMPLETED]).isEqualTo(now.toEpochMilliseconds()) + } + + @Test + fun `an override replaces the occurrence it names`() { + val start = Instant.parse("2026-10-09T09:00:00Z") + val master = TaskEntity( + id = 10, listId = 1, uid = "s", title = "Run", dtstart = start, due = start + 1.hours, + rrule = "FREQ=DAILY;COUNT=3", timezone = "UTC", + ) + val second = start + 1.days + val override = master.copy( + id = 11, rrule = null, recurrenceId = second, masterId = 10, title = "Run long", + dtstart = second + 2.hours, due = second + 4.hours, + ) + + val snapshot = build(listOf(master, override)) + val instances = snapshot.rows.getValue(ProviderTable.INSTANCES) + + assertThat(instances.map { it[Instances.TASK_ID] }).containsExactly(10L, 11L, 10L).inOrder() + assertThat(instances.map { it[Tasks.TITLE] }).containsExactly("Run", "Run long", "Run").inOrder() + assertThat(instances[1][Instances.INSTANCE_ORIGINAL_TIME]).isEqualTo(second.toEpochMilliseconds()) + assertThat(instances[1][Instances.INSTANCE_DURATION]).isEqualTo(2.hours.inWholeMilliseconds) + assertThat(instances.map { it[Tasks.ID] }).containsNoDuplicates() + + val overrideRow = snapshot.rows.getValue(ProviderTable.TASKS).single { it[Tasks.ID] == 11L } + assertThat(overrideRow[Tasks.ORIGINAL_INSTANCE_ID]).isEqualTo(10L) + assertThat(overrideRow[Tasks.ORIGINAL_INSTANCE_TIME]).isEqualTo(second.toEpochMilliseconds()) + } + + @Test + fun `an all-day date sorts at local midnight`() { + val day = Instant.parse("2026-10-12T00:00:00Z") + val task = TaskEntity(id = 1, listId = 1, uid = "u", due = day, isAllDay = true) + + val instance = build(listOf(task)).rows.getValue(ProviderTable.INSTANCES).single() + + assertThat(instance[Instances.INSTANCE_DUE]).isEqualTo(day.toEpochMilliseconds()) + assertThat(instance[Instances.INSTANCE_DUE_SORTING]).isEqualTo((day - 2.hours).toEpochMilliseconds()) + } + + @Test + fun `alarms and tags become properties`() { + val task = TaskEntity(id = 3, listId = 2, uid = "u", categories = listOf("errand", "home")) + val alarm = TaskAlarmEntity(id = 1, taskId = 3, minutesBefore = 15) + + val snapshot = build(listOf(task), listOf(alarm)) + val properties = snapshot.rows.getValue(ProviderTable.PROPERTIES) + + assertThat(snapshot.rows.getValue(ProviderTable.TASKS).single()[Tasks.HAS_ALARMS]).isEqualTo(1) + val alarmRow = properties.single { it[Properties.MIMETYPE] == Alarm.MIMETYPE } + assertThat(alarmRow[Alarm.MINUTES_BEFORE]).isEqualTo("15") + assertThat(alarmRow[Alarm.REFERENCE]).isEqualTo(Alarm.REFERENCE_DUE.toString()) + assertThat(properties.filter { it[Properties.MIMETYPE] == Category.MIMETYPE }.map { it[Category.NAME] }) + .containsExactly("errand", "home") + assertThat(properties.map { it[Properties.PROPERTY_ID] }).containsNoDuplicates() + } + + @Test + fun `every row carries exactly its table's columns`() { + val task = TaskEntity(id = 3, listId = 1, uid = "u", categories = listOf("a")) + val snapshot = build(listOf(task), listOf(TaskAlarmEntity(id = 1, taskId = 3, minutesBefore = 0))) + + ProviderTable.entries.forEach { table -> + val columns = table.columns.map { it.first }.toSet() + snapshot.rows.getValue(table).forEach { assertThat(columns).containsAtLeastElementsIn(it.keys) } + } + } + + @Test + fun `the empty snapshot has every table and no rows`() { + assertThat(ProviderSnapshot.EMPTY.rows.keys).containsExactlyElementsIn(ProviderTable.entries) + assertThat(ProviderSnapshot.EMPTY.rows.values.flatten()).isEmpty() + } + + @Test + fun `lists carry their name, colour and flags`() { + val hidden = TaskListRow(TaskListEntity(id = 3, name = "Old", color = 7, isVisible = false, isSynced = false), null) + + val row = ProviderSnapshot.build(listOf(hidden), emptyList(), emptyList(), "t", now) + .rows.getValue(ProviderTable.LISTS).single() + + assertThat(row[Lists.ID]).isEqualTo(3L) + assertThat(row[Lists.NAME]).isEqualTo("Old") + assertThat(row[Lists.COLOR]).isEqualTo(7) + assertThat(row[Lists.VISIBLE]).isEqualTo(0) + assertThat(row[Lists.SYNC_ENABLED]).isEqualTo(0) + } + + @Test + fun `a task reports its list's account`() { + val task = TaskEntity(id = 1, listId = 2, uid = "u") + + val row = build(listOf(task)).rows.getValue(ProviderTable.TASKS).single() + + assertThat(row[Tasks.LIST_NAME]).isEqualTo("Work") + assertThat(row[Tasks.ACCOUNT_NAME]).isEqualTo("Nextcloud") + assertThat(row[Tasks.ACCOUNT_TYPE]).isEqualTo("caldav-type") + } + + @Test + fun `a task whose list is gone is left out everywhere`() { + val orphan = TaskEntity(id = 4, listId = 99, uid = "u", categories = listOf("x")) + val alarm = TaskAlarmEntity(id = 1, taskId = 4, minutesBefore = 5) + + val snapshot = build(listOf(orphan), listOf(alarm)) + + assertThat(snapshot.rows.getValue(ProviderTable.TASKS)).isEmpty() + assertThat(snapshot.rows.getValue(ProviderTable.INSTANCES)).isEmpty() + assertThat(snapshot.rows.getValue(ProviderTable.PROPERTIES)).isEmpty() + } + + @Test + fun `task fields map to their OpenTasks columns`() { + val start = Instant.parse("2026-10-11T09:00:00Z") + val task = TaskEntity( + id = 5, listId = 1, uid = "uid-5", title = "T", description = "D", location = "L", url = "https://x", + color = 42, classification = 1, percentComplete = 50, dtstart = start, due = start + 1.hours, + timezone = "Europe/Berlin", parentId = 2, sortOrder = 3, createdAt = now, lastModified = now, + ) + + val row = build(listOf(task)).rows.getValue(ProviderTable.TASKS).single() + + assertThat(row[Tasks.UID]).isEqualTo("uid-5") + assertThat(row[Tasks.TITLE]).isEqualTo("T") + assertThat(row[Tasks.DESCRIPTION]).isEqualTo("D") + assertThat(row[Tasks.LOCATION]).isEqualTo("L") + assertThat(row[Tasks.URL]).isEqualTo("https://x") + assertThat(row[Tasks.TASK_COLOR]).isEqualTo(42) + assertThat(row[Tasks.CLASSIFICATION]).isEqualTo(1) + assertThat(row[Tasks.PERCENT_COMPLETE]).isEqualTo(50) + assertThat(row[Tasks.DTSTART]).isEqualTo(start.toEpochMilliseconds()) + assertThat(row[Tasks.DUE]).isEqualTo((start + 1.hours).toEpochMilliseconds()) + assertThat(row[Tasks.TZ]).isEqualTo("Europe/Berlin") + assertThat(row[Tasks.IS_ALLDAY]).isEqualTo(0) + assertThat(row[Tasks.PARENT_ID]).isEqualTo(2L) + assertThat(row[Tasks.SORTING]).isEqualTo(3) + assertThat(row[Tasks.CREATED]).isEqualTo(now.toEpochMilliseconds()) + assertThat(row[Tasks.LAST_MODIFIED]).isEqualTo(now.toEpochMilliseconds()) + assertThat(row[Tasks.DELETED]).isEqualTo(0) + assertThat(row[Tasks.HAS_ALARMS]).isEqualTo(0) + assertThat(row[Tasks.ORIGINAL_INSTANCE_ID]).isNull() + assertThat(row[Tasks.ORIGINAL_INSTANCE_ALLDAY]).isNull() + } + + @Test + fun `a task with progress is no longer new`() { + val task = TaskEntity(id = 1, listId = 1, uid = "u", percentComplete = 10) + + assertThat(build(listOf(task)).rows.getValue(ProviderTable.TASKS).single()[Tasks.IS_NEW]).isEqualTo(0) + } + + @Test + fun `a cancelled task is closed`() { + val task = TaskEntity(id = 1, listId = 1, uid = "u", status = TaskStatus.CANCELLED) + + val row = build(listOf(task)).rows.getValue(ProviderTable.TASKS).single() + + assertThat(row[Tasks.IS_CLOSED]).isEqualTo(1) + assertThat(row[Tasks.STATUS]).isEqualTo(TasksContract.STATUS_CANCELLED) + } + + @Test + fun `a series expands into one instance per occurrence`() { + val start = Instant.parse("2026-10-09T09:00:00Z") + val master = TaskEntity( + id = 10, listId = 1, uid = "s", dtstart = start, due = start + 1.hours, + rrule = "FREQ=DAILY;COUNT=3", timezone = "UTC", + ) + + val snapshot = build(listOf(master)) + val instances = snapshot.rows.getValue(ProviderTable.INSTANCES) + + assertThat(snapshot.rows.getValue(ProviderTable.TASKS)).hasSize(1) + assertThat(instances.map { it[Instances.TASK_ID] }).containsExactly(10L, 10L, 10L) + assertThat(instances.map { it[Instances.IS_RECURRING] }).containsExactly(1, 1, 1) + assertThat(instances.map { it[Instances.INSTANCE_START] }).containsExactlyElementsIn( + (0..2).map { (start + it.days).toEpochMilliseconds() }, + ).inOrder() + assertThat(instances.map { it[Instances.INSTANCE_ORIGINAL_TIME] }) + .isEqualTo(instances.map { it[Instances.INSTANCE_START] }) + assertThat(instances.map { it[Instances.INSTANCE_DURATION] }).containsExactly( + 1.hours.inWholeMilliseconds, 1.hours.inWholeMilliseconds, 1.hours.inWholeMilliseconds, + ) + // 10-09 is past, 10-10 09:00 is the next one at or after 08:00. + assertThat(instances.map { it[Instances.DISTANCE_FROM_CURRENT] }).containsExactly(-1, 0, 1).inOrder() + } + + @Test + fun `a due-anchored series has no start`() { + val due = Instant.parse("2026-10-12T17:00:00Z") + val master = TaskEntity(id = 10, listId = 1, uid = "s", due = due, rrule = "FREQ=WEEKLY;COUNT=2", timezone = "UTC") + + val instances = build(listOf(master)).rows.getValue(ProviderTable.INSTANCES) + + assertThat(instances.map { it[Instances.INSTANCE_START] }).containsExactly(null, null) + assertThat(instances.map { it[Instances.INSTANCE_DUE] }).containsExactly( + due.toEpochMilliseconds(), (due + 7.days).toEpochMilliseconds(), + ).inOrder() + assertThat(instances.map { it[Instances.INSTANCE_DURATION] }).containsExactly(null, null) + } + + @Test + fun `instance ids are unique across series and plain tasks`() { + val start = Instant.parse("2026-10-09T09:00:00Z") + val a = TaskEntity(id = 1, listId = 1, uid = "a", dtstart = start, rrule = "FREQ=DAILY;COUNT=4", timezone = "UTC") + val b = TaskEntity(id = 2, listId = 1, uid = "b", dtstart = start, rrule = "FREQ=DAILY;COUNT=4", timezone = "UTC") + val plain = TaskEntity(id = 3, listId = 2, uid = "c") + + val ids = build(listOf(a, b, plain)).rows.getValue(ProviderTable.INSTANCES).map { it[Tasks.ID] } + + assertThat(ids).hasSize(9) + assertThat(ids).containsNoDuplicates() + } + + @Test + fun `an override is flagged all-day like the occurrence it replaces`() { + val day = Instant.parse("2026-10-09T00:00:00Z") + val master = TaskEntity(id = 1, listId = 1, uid = "s", due = day, isAllDay = true, rrule = "FREQ=DAILY;COUNT=2") + val override = master.copy(id = 2, rrule = null, masterId = 1, recurrenceId = day + 1.days) + + val row = build(listOf(master, override)).rows.getValue(ProviderTable.TASKS).single { it[Tasks.ID] == 2L } + + assertThat(row[Tasks.ORIGINAL_INSTANCE_ALLDAY]).isEqualTo(1) + } + + @Test + fun `a timed date sorts as itself`() { + val due = Instant.parse("2026-10-12T15:30:00Z") + val task = TaskEntity(id = 1, listId = 1, uid = "u", due = due, dtstart = due - 1.hours) + + val instance = build(listOf(task)).rows.getValue(ProviderTable.INSTANCES).single() + + assertThat(instance[Instances.INSTANCE_DUE_SORTING]).isEqualTo(due.toEpochMilliseconds()) + assertThat(instance[Instances.INSTANCE_START_SORTING]).isEqualTo((due - 1.hours).toEpochMilliseconds()) + } + + @Test + fun `an alarm before the start says so`() { + val task = TaskEntity(id = 3, listId = 1, uid = "u") + val alarm = TaskAlarmEntity(id = 1, taskId = 3, minutesBefore = 60, reference = AlarmReference.START, message = "Go") + + val row = build(listOf(task), listOf(alarm)).rows.getValue(ProviderTable.PROPERTIES).single() + + assertThat(row[Properties.TASK_ID]).isEqualTo(3L) + assertThat(row[Alarm.REFERENCE]).isEqualTo(Alarm.REFERENCE_START.toString()) + assertThat(row[Alarm.MESSAGE]).isEqualTo("Go") + assertThat(row[Alarm.ALARM_TYPE]).isEqualTo(Alarm.TYPE_MESSAGE.toString()) + } + + @Test + fun `an instance keeps its id when other occurrences come and go`() { + val start = Instant.parse("2026-10-09T09:00:00Z") + val master = TaskEntity(id = 10, listId = 1, uid = "s", dtstart = start, rrule = "FREQ=DAILY;COUNT=4", timezone = "UTC") + val plain = TaskEntity(id = 3, listId = 1, uid = "p") + fun ids(vararg tasks: TaskEntity) = build(tasks.toList()).rows.getValue(ProviderTable.INSTANCES) + .associate { it[Instances.INSTANCE_ORIGINAL_TIME] to it[Tasks.ID] } + + val before = ids(plain, master) + val after = ids(master.copy(exdate = "20261010T090000Z")) + + val third = (start + 2.days).toEpochMilliseconds() + assertThat(after[third]).isEqualTo(before[third]) + assertThat(after).hasSize(3) + } + + @Test + fun `an overridden occurrence keeps the id it had before`() { + val start = Instant.parse("2026-10-09T09:00:00Z") + val master = TaskEntity(id = 10, listId = 1, uid = "s", dtstart = start, rrule = "FREQ=DAILY;COUNT=3", timezone = "UTC") + val override = master.copy(id = 11, rrule = null, masterId = 10, recurrenceId = start + 1.days, title = "x") + fun idOfSecond(vararg tasks: TaskEntity) = build(tasks.toList()).rows.getValue(ProviderTable.INSTANCES) + .single { it[Instances.INSTANCE_ORIGINAL_TIME] == (start + 1.days).toEpochMilliseconds() }[Tasks.ID] + + assertThat(idOfSecond(master, override)).isEqualTo(idOfSecond(master)) + } + + @Test + fun `a property keeps its id when other properties come and go`() { + val a = TaskEntity(id = 3, listId = 1, uid = "a", categories = listOf("one", "two")) + val b = TaskEntity(id = 4, listId = 1, uid = "b", categories = listOf("three")) + fun ids(tasks: List, alarms: List) = build(tasks, alarms) + .rows.getValue(ProviderTable.PROPERTIES) + .associate { (it[Category.NAME] ?: it[Alarm.MINUTES_BEFORE]) to it[Properties.PROPERTY_ID] } + val first = TaskAlarmEntity(id = 7, taskId = 3, minutesBefore = 5) + val second = TaskAlarmEntity(id = 8, taskId = 4, minutesBefore = 10) + + val before = ids(listOf(a, b), listOf(first, second)) + val after = ids(listOf(a.copy(categories = listOf("two")), b), listOf(second)) + + assertThat(after["two"]).isEqualTo(before["two"]) + assertThat(after["three"]).isEqualTo(before["three"]) + assertThat(after["10"]).isEqualTo(before["10"]) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderTableTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderTableTest.kt new file mode 100644 index 0000000..f74153d --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderTableTest.kt @@ -0,0 +1,70 @@ +package de.jeanlucmakiola.agendula.data.tasks.provider + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks +import org.junit.jupiter.api.Test + +class ProviderTableTest { + + @Test + fun `paths follow the OpenTasks contract`() { + assertThat(ProviderTable.entries.map { it.path }) + .containsExactly("tasklists", "tasks", "instances", "properties").inOrder() + } + + @Test + fun `each table keys on its OpenTasks id column`() { + assertThat(ProviderTable.LISTS.idColumn).isEqualTo(Lists.ID) + assertThat(ProviderTable.TASKS.idColumn).isEqualTo(Tasks.ID) + assertThat(ProviderTable.INSTANCES.idColumn).isEqualTo(Tasks.ID) + assertThat(ProviderTable.PROPERTIES.idColumn).isEqualTo(Properties.PROPERTY_ID) + } + + @Test + fun `no table declares a column twice`() { + ProviderTable.entries.forEach { table -> + assertThat(table.columns.map { it.first }).containsNoDuplicates() + } + } + + @Test + fun `instances carry every task column`() { + val tasks = ProviderTable.TASKS.columns.map { it.first } + val instances = ProviderTable.INSTANCES.columns.map { it.first } + + assertThat(instances).containsAtLeastElementsIn(tasks) + assertThat(instances).containsAtLeast(Instances.TASK_ID, Instances.INSTANCE_ORIGINAL_TIME) + } + + @Test + fun `unsupported OpenTasks columns are declared so a projection naming them succeeds`() { + assertThat(ProviderTable.TASKS.columns.map { it.first }).containsAtLeast("_dirty", "_sync_id", "sync1", "pinned") + } + + @Test + fun `a table URI keeps the caller's selection untouched`() { + assertThat(ProviderTable.TASKS.selectionFor(null, "title = ?")).isEqualTo("title = ?") + assertThat(ProviderTable.TASKS.selectionFor(null, null)).isNull() + } + + @Test + fun `a row URI narrows to its id`() { + assertThat(ProviderTable.TASKS.selectionFor(4, null)).isEqualTo("_id = 4") + assertThat(ProviderTable.PROPERTIES.selectionFor(9, null)).isEqualTo("property_id = 9") + } + + @Test + fun `a row URI parenthesises the caller's selection so an OR cannot widen it`() { + assertThat(ProviderTable.TASKS.selectionFor(4, "a = 1 OR b = 2")).isEqualTo("_id = 4 AND (a = 1 OR b = 2)") + } + + @Test + fun `MIME types are OpenTasks' own`() { + assertThat(ProviderTable.TASKS.mimeType(single = false)).isEqualTo("vnd.android.cursor.dir/org.dmfs.tasks.tasks") + assertThat(ProviderTable.LISTS.mimeType(single = true)) + .isEqualTo("vnd.android.cursor.item/org.dmfs.tasks.tasklists") + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderTaskWriterTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderTaskWriterTest.kt new file mode 100644 index 0000000..41c4967 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderTaskWriterTest.kt @@ -0,0 +1,490 @@ +package de.jeanlucmakiola.agendula.data.tasks.provider + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.agendula.data.tasks.TasksContract +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks +import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity +import de.jeanlucmakiola.agendula.domain.TaskStatus +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Instant + +class ProviderTaskWriterTest { + + private val now = Instant.parse("2026-10-15T10:00:00Z") + private val earlier = Instant.parse("2026-10-01T10:00:00Z") + private val task = TaskEntity(id = 5, listId = 1, uid = "uid-5", title = "Old", lastModified = earlier) + + private fun patch(current: TaskEntity = task, vararg values: Pair) = + ProviderTaskWriter.patch(current, mapOf(*values), now, "Europe/Berlin") + + private fun millis(at: Instant) = at.toEpochMilliseconds() + + // --- column filtering --------------------------------------------------------- + + @Test + fun `published read-only columns are dropped`() { + val values = mapOf(Tasks.TITLE to "t", Tasks.LIST_NAME to "x", Tasks.IS_CLOSED to 1, "_sync_id" to "s") + + assertThat(ProviderTaskWriter.writable(ProviderTable.TASKS, values, WRITABLE_TASK_COLUMNS)) + .containsExactly(Tasks.TITLE, "t") + } + + @Test + fun `a column the table does not have is rejected`() { + val error = assertThrows { + ProviderTaskWriter.writable(ProviderTable.TASKS, mapOf("href" to "x", "etag" to "y"), WRITABLE_TASK_COLUMNS) + } + assertThat(error).hasMessageThat().contains("etag, href") + } + + @Test + fun `instances accept every task column and their own`() { + val values = mapOf(Tasks.STATUS to 2, Instances.INSTANCE_START to 0L) + + assertThat(ProviderTaskWriter.writable(ProviderTable.INSTANCES, values, WRITABLE_TASK_COLUMNS)) + .containsExactly(Tasks.STATUS, 2) + } + + // --- new tasks ---------------------------------------------------------------- + + @Test + fun `a new task needs a list`() { + assertThrows { + ProviderTaskWriter.newTask(mapOf(Tasks.TITLE to "t"), "u", now, "UTC") + } + } + + @Test + fun `a new task takes the client's uid or the minted one`() { + val minted = ProviderTaskWriter.newTask(mapOf(Tasks.LIST_ID to 3L), "minted", now, "UTC") + val own = ProviderTaskWriter.newTask(mapOf(Tasks.LIST_ID to 3L, Tasks.UID to " theirs "), "minted", now, "UTC") + + assertThat(minted.uid).isEqualTo("minted") + assertThat(own.uid).isEqualTo("theirs") + assertThat(own.listId).isEqualTo(3L) + assertThat(own.createdAt).isEqualTo(now) + assertThat(own.lastModified).isEqualTo(now) + assertThat(own.isDirty).isTrue() + } + + @Test + fun `a new task may have no title`() { + assertThat(ProviderTaskWriter.newTask(mapOf(Tasks.LIST_ID to 3L), "u", now, "UTC").title).isNull() + } + + // --- content ------------------------------------------------------------------ + + @Test + fun `only the columns given change`() { + val current = task.copy(description = "keep", priority = 3, location = "home") + + val edited = patch(current, Tasks.TITLE to " New ") + + assertThat(edited.title).isEqualTo("New") + assertThat(edited.description).isEqualTo("keep") + assertThat(edited.priority).isEqualTo(3) + assertThat(edited.location).isEqualTo("home") + assertThat(edited.lastModified).isEqualTo(now) + assertThat(edited.isDirty).isTrue() + } + + @Test + fun `blank text clears and null clears`() { + val current = task.copy(description = "d", url = "u") + + val edited = patch(current, Tasks.DESCRIPTION to " ", Tasks.URL to null) + + assertThat(edited.description).isNull() + assertThat(edited.url).isNull() + } + + @Test + fun `numbers arrive in whatever shape the client sent`() { + val edited = patch(task, Tasks.PRIORITY to "2", Tasks.CLASSIFICATION to 1L, Tasks.SORTING to 7, Tasks.TASK_COLOR to -16711936) + + assertThat(edited.priority).isEqualTo(2) + assertThat(edited.classification).isEqualTo(1) + assertThat(edited.sortOrder).isEqualTo(7) + assertThat(edited.color).isEqualTo(-16711936) + } + + @Test + fun `a null priority is no priority`() { + assertThat(patch(task.copy(priority = 4), Tasks.PRIORITY to null).priority).isEqualTo(TasksContract.PRIORITY_NONE) + } + + @Test + fun `out of range values are rejected`() { + assertThrows { patch(task, Tasks.PRIORITY to 10) } + assertThrows { patch(task, Tasks.CLASSIFICATION to 3) } + assertThrows { patch(task, Tasks.PERCENT_COMPLETE to 101) } + assertThrows { patch(task, Tasks.STATUS to 4) } + assertThrows { patch(task, Tasks.PRIORITY to "high") } + } + + @Test + fun `the uid cannot change`() { + assertThat(patch(task, Tasks.UID to "uid-5").uid).isEqualTo("uid-5") + assertThrows { patch(task, Tasks.UID to "other") } + } + + @Test + fun `a task cannot be its own parent`() { + assertThrows { patch(task, Tasks.PARENT_ID to 5L) } + assertThat(patch(task, Tasks.PARENT_ID to 9L).parentId).isEqualTo(9L) + assertThat(patch(task.copy(parentId = 9), Tasks.PARENT_ID to null).parentId).isNull() + } + + @Test + fun `a list can be changed but not cleared`() { + assertThat(patch(task, Tasks.LIST_ID to 2L).listId).isEqualTo(2L) + assertThrows { patch(task, Tasks.LIST_ID to null) } + } + + @Test + fun `sync state is left alone`() { + val synced = task.copy(href = "/t.ics", etag = "e1", sequence = 4, unknownProperties = "X-FOO:bar") + + val edited = patch(synced, Tasks.TITLE to "t") + + assertThat(edited.href).isEqualTo("/t.ics") + assertThat(edited.etag).isEqualTo("e1") + assertThat(edited.sequence).isEqualTo(4) + assertThat(edited.unknownProperties).isEqualTo("X-FOO:bar") + } + + // --- times -------------------------------------------------------------------- + + @Test + fun `a timed task takes the device zone unless it has one`() { + val due = Instant.parse("2026-10-20T15:00:00Z") + + assertThat(patch(task, Tasks.DUE to millis(due)).timezone).isEqualTo("Europe/Berlin") + assertThat(patch(task.copy(timezone = "UTC"), Tasks.DUE to millis(due)).timezone).isEqualTo("UTC") + assertThat(patch(task, Tasks.DUE to millis(due), Tasks.TZ to "America/New_York").timezone) + .isEqualTo("America/New_York") + } + + @Test + fun `a new timed task without a zone is floating`() { + val due = millis(Instant.parse("2026-10-20T09:00:00Z")) + + assertThat(ProviderTaskWriter.newTask(mapOf(Tasks.LIST_ID to 3L, Tasks.DUE to due), "u", now, "UTC").timezone).isNull() + assertThat(ProviderTaskWriter.newTask(mapOf(Tasks.LIST_ID to 3L, Tasks.DUE to due, Tasks.TZ to "Europe/Paris"), "u", now, "UTC").timezone) + .isEqualTo("Europe/Paris") + } + + @Test + fun `an unknown zone is rejected`() { + assertThrows { patch(task, Tasks.DUE to 0L, Tasks.TZ to "Mars/Olympus") } + } + + @Test + fun `an all-day task is floored to UTC midnight and has no zone`() { + val due = Instant.parse("2026-10-20T15:00:00Z") + + val edited = patch(task.copy(timezone = "UTC"), Tasks.DUE to millis(due), Tasks.IS_ALLDAY to 1) + + assertThat(edited.isAllDay).isTrue() + assertThat(edited.due).isEqualTo(Instant.parse("2026-10-20T00:00:00Z")) + assertThat(edited.timezone).isNull() + } + + @Test + fun `due before start is rejected`() { + assertThrows { + patch(task, Tasks.DTSTART to millis(now), Tasks.DUE to millis(now - 1.hours)) + } + } + + @Test + fun `setting a due date drops a duration`() { + val edited = patch(task.copy(dtstart = now, duration = "PT1H"), Tasks.DUE to millis(now + 2.hours)) + + assertThat(edited.duration).isNull() + assertThat(edited.due).isEqualTo(now + 2.hours) + } + + @Test + fun `due and duration together are rejected`() { + assertThrows { + patch(task, Tasks.DTSTART to millis(now), Tasks.DUE to millis(now + 1.hours), Tasks.DURATION to "PT1H") + } + } + + @Test + fun `a duration must be an iCalendar one`() { + assertThat(patch(task, Tasks.DTSTART to millis(now), Tasks.DURATION to "P1W").duration).isEqualTo("P1W") + assertThat(patch(task, Tasks.DTSTART to millis(now), Tasks.DURATION to "PT1H30M").duration).isEqualTo("PT1H30M") + assertThrows { patch(task, Tasks.DURATION to "an hour") } + } + + // --- recurrence --------------------------------------------------------------- + + @Test + fun `a rule is stored without its property name`() { + assertThat(patch(task, Tasks.DUE to millis(now), Tasks.RRULE to "RRULE:FREQ=WEEKLY").rrule).isEqualTo("FREQ=WEEKLY") + } + + @Test + fun `a malformed rule is rejected`() { + assertThrows { patch(task, Tasks.DUE to millis(now), Tasks.RRULE to "FREQ=SOMETIMES") } + } + + @Test + fun `a rule needs a date`() { + assertThrows { patch(task, Tasks.RRULE to "FREQ=DAILY") } + } + + @Test + fun `dropping the rule drops its exceptions`() { + val series = task.copy(due = now, rrule = "FREQ=DAILY", exdate = "20261016T100000Z", rdate = "20261030T100000Z") + + val edited = patch(series, Tasks.RRULE to null) + + assertThat(edited.rrule).isNull() + assertThat(edited.exdate).isNull() + assertThat(edited.rdate).isNull() + } + + @Test + fun `an override carries no rule and stays in its series`() { + val override = task.copy(masterId = 4, recurrenceId = now, parentId = 2) + + assertThrows { patch(override, Tasks.RRULE to "FREQ=DAILY") } + assertThat(patch(override, Tasks.RRULE to null, Tasks.RDATE to null, Tasks.EXDATE to "").rrule).isNull() + assertThrows { patch(override, Tasks.LIST_ID to 2L) } + assertThrows { patch(override, Tasks.PARENT_ID to 3L) } + assertThat(patch(override, Tasks.LIST_ID to 1L, Tasks.PARENT_ID to 2L, Tasks.TITLE to "x").title).isEqualTo("x") + } + + // --- completion --------------------------------------------------------------- + + @Test + fun `completing sets progress and time`() { + val edited = patch(task, Tasks.STATUS to TasksContract.STATUS_COMPLETED) + + assertThat(edited.status).isEqualTo(TaskStatus.COMPLETED) + assertThat(edited.percentComplete).isEqualTo(100) + assertThat(edited.completedAt).isEqualTo(now) + } + + @Test + fun `completing keeps the client's completion time`() { + val edited = patch(task, Tasks.STATUS to TasksContract.STATUS_COMPLETED, Tasks.COMPLETED to millis(earlier)) + + assertThat(edited.completedAt).isEqualTo(earlier) + } + + @Test + fun `reopening clears progress and time`() { + val done = task.copy(status = TaskStatus.COMPLETED, percentComplete = 100, completedAt = earlier) + + val edited = patch(done, Tasks.STATUS to TasksContract.STATUS_NEEDS_ACTION) + + assertThat(edited.status).isEqualTo(TaskStatus.NEEDS_ACTION) + assertThat(edited.percentComplete).isNull() + assertThat(edited.completedAt).isNull() + } + + @Test + fun `cancelling keeps progress and clears the completion time`() { + val edited = patch(task.copy(percentComplete = 40, completedAt = earlier), Tasks.STATUS to TasksContract.STATUS_CANCELLED) + + assertThat(edited.status).isEqualTo(TaskStatus.CANCELLED) + assertThat(edited.percentComplete).isEqualTo(40) + assertThat(edited.completedAt).isNull() + } + + @Test + fun `progress implies a status`() { + assertThat(patch(task, Tasks.PERCENT_COMPLETE to 50).status).isEqualTo(TaskStatus.IN_PROCESS) + assertThat(patch(task, Tasks.PERCENT_COMPLETE to 0).status).isEqualTo(TaskStatus.NEEDS_ACTION) + val done = patch(task, Tasks.PERCENT_COMPLETE to 100) + assertThat(done.status).isEqualTo(TaskStatus.COMPLETED) + assertThat(done.completedAt).isEqualTo(now) + } + + @Test + fun `progress below 100 reopens a completed task`() { + val done = task.copy(status = TaskStatus.COMPLETED, percentComplete = 100, completedAt = earlier) + + val edited = patch(done, Tasks.PERCENT_COMPLETE to 60) + + assertThat(edited.status).isEqualTo(TaskStatus.IN_PROCESS) + assertThat(edited.completedAt).isNull() + } + + @Test + fun `a completion time alone completes and clearing it reopens`() { + val done = patch(task, Tasks.COMPLETED to millis(earlier)) + assertThat(done.status).isEqualTo(TaskStatus.COMPLETED) + assertThat(done.percentComplete).isEqualTo(100) + assertThat(done.completedAt).isEqualTo(earlier) + + val reopened = patch(done, Tasks.COMPLETED to null) + assertThat(reopened.status).isEqualTo(TaskStatus.NEEDS_ACTION) + assertThat(reopened.completedAt).isNull() + } + + @Test + fun `a completed row written back with status open reopens it fully`() { + val done = task.copy(status = TaskStatus.COMPLETED, percentComplete = 100, completedAt = earlier) + + val edited = patch(done, Tasks.STATUS to 0, Tasks.PERCENT_COMPLETE to 100, Tasks.COMPLETED to millis(earlier)) + + assertThat(edited.status).isEqualTo(TaskStatus.NEEDS_ACTION) + assertThat(edited.percentComplete).isNull() + assertThat(edited.completedAt).isNull() + } + + @Test + fun `the changed completion column decides over unchanged ones written back`() { + val completed = patch(task, Tasks.STATUS to 0, Tasks.PERCENT_COMPLETE to 100, Tasks.COMPLETED to null) + assertThat(completed.status).isEqualTo(TaskStatus.COMPLETED) + assertThat(completed.completedAt).isEqualTo(now) + + val done = task.copy(status = TaskStatus.COMPLETED, percentComplete = 100, completedAt = earlier) + val reopened = patch(done, Tasks.STATUS to 2, Tasks.PERCENT_COMPLETE to 100, Tasks.COMPLETED to null) + assertThat(reopened.status).isEqualTo(TaskStatus.NEEDS_ACTION) + assertThat(reopened.percentComplete).isNull() + } + + @Test + fun `a write without completion columns leaves completion alone`() { + val done = task.copy(status = TaskStatus.COMPLETED, percentComplete = 100, completedAt = earlier) + + val edited = patch(done, Tasks.TITLE to "t") + + assertThat(edited.status).isEqualTo(TaskStatus.COMPLETED) + assertThat(edited.completedAt).isEqualTo(earlier) + } + + // --- occurrences -------------------------------------------------------------- + + @Test + fun `an occurrence is completed, cancelled or reopened`() { + assertThat(ProviderTaskWriter.occurrenceChange(mapOf(Tasks.STATUS to 2))).isEqualTo(OccurrenceChange.COMPLETE) + assertThat(ProviderTaskWriter.occurrenceChange(mapOf(Tasks.STATUS to 3))).isEqualTo(OccurrenceChange.CANCEL) + assertThat(ProviderTaskWriter.occurrenceChange(mapOf(Tasks.STATUS to 0))).isEqualTo(OccurrenceChange.REOPEN) + assertThat(ProviderTaskWriter.occurrenceChange(mapOf(Tasks.PERCENT_COMPLETE to 100))).isEqualTo(OccurrenceChange.COMPLETE) + assertThat(ProviderTaskWriter.occurrenceChange(mapOf(Tasks.PERCENT_COMPLETE to 0))).isEqualTo(OccurrenceChange.REOPEN) + assertThat(ProviderTaskWriter.occurrenceChange(mapOf(Tasks.COMPLETED to 1L))).isEqualTo(OccurrenceChange.COMPLETE) + assertThat(ProviderTaskWriter.occurrenceChange(mapOf(Tasks.COMPLETED to null))).isEqualTo(OccurrenceChange.REOPEN) + } + + @Test + fun `status wins over progress on an occurrence`() { + assertThat(ProviderTaskWriter.occurrenceChange(mapOf(Tasks.STATUS to 2, Tasks.PERCENT_COMPLETE to 100))) + .isEqualTo(OccurrenceChange.COMPLETE) + } + + @Test + fun `an occurrence has no partial progress`() { + assertThrows { ProviderTaskWriter.occurrenceChange(mapOf(Tasks.STATUS to 1)) } + assertThrows { ProviderTaskWriter.occurrenceChange(mapOf(Tasks.PERCENT_COMPLETE to 50)) } + } + + @Test + fun `values without completion say nothing about the occurrence`() { + assertThat(ProviderTaskWriter.occurrenceChange(mapOf(Tasks.TITLE to "t"))).isNull() + } + + @Test + fun `flags accept the shapes clients send`() { + listOf(1, 1L, true, "1", "true").forEach { + assertThat(patch(task, Tasks.DUE to millis(now + 1.days), Tasks.IS_ALLDAY to it).isAllDay).isTrue() + } + listOf(0, false, "0", null).forEach { + assertThat(patch(task.copy(isAllDay = true), Tasks.IS_ALLDAY to it).isAllDay).isFalse() + } + assertThrows { patch(task, Tasks.IS_ALLDAY to "yes") } + } + + // --- no-op writes ------------------------------------------------------------- + + @Test + fun `a write that changes nothing returns the row as it was`() { + val current = task.copy(description = "d", priority = 3, isDirty = false) + + assertThat(patch(current)).isSameInstanceAs(current) + assertThat(patch(current, Tasks.TITLE to "Old", Tasks.PRIORITY to 3L, Tasks.DESCRIPTION to "d")) + .isSameInstanceAs(current) + assertThat(patch(current, Tasks.TITLE to "New").isDirty).isTrue() + } + + @Test + fun `only columns that differ from the row count as changed`() { + val due = Instant.parse("2026-10-20T15:00:00Z") + val series = task.copy(due = due, timezone = "UTC", rrule = "FREQ=DAILY", status = TaskStatus.IN_PROCESS, percentComplete = 50) + val readBack = mapOf( + Tasks.TITLE to "Old", Tasks.DUE to millis(due), Tasks.DTSTART to null, Tasks.TZ to "UTC", Tasks.IS_ALLDAY to 0, + Tasks.RRULE to "FREQ=DAILY", Tasks.RDATE to null, Tasks.EXDATE to null, Tasks.DURATION to null, + Tasks.STATUS to 1, Tasks.PERCENT_COMPLETE to 50, Tasks.COMPLETED to null, + ) + + assertThat(ProviderTaskWriter.changedColumns(series, readBack, "Europe/Berlin")).isEmpty() + assertThat( + ProviderTaskWriter.changedColumns( + series, + readBack + (Tasks.DUE to millis(due + 1.hours)) + (Tasks.TITLE to "New") + (Tasks.STATUS to 2), + "Europe/Berlin", + ), + ).containsExactly(Tasks.DUE, Tasks.TITLE, Tasks.STATUS) + } + + @Test + fun `a value that does not fit counts as changed`() { + assertThat(ProviderTaskWriter.changedColumns(task, mapOf(Tasks.PRIORITY to 99), "UTC")).containsExactly(Tasks.PRIORITY) + } + + @Test + fun `a floating task stays floating`() { + val floating = task.copy(due = now, timezone = null) + + assertThat(patch(floating, Tasks.TITLE to "t").timezone).isNull() + assertThat(patch(floating, Tasks.DUE to millis(now + 1.hours)).timezone).isNull() + assertThat(patch(floating, Tasks.TITLE to "Old")).isSameInstanceAs(floating) + } + + @Test + fun `an all-day task made timed takes the device zone`() { + val allDay = task.copy(due = Instant.parse("2026-10-20T00:00:00Z"), isAllDay = true) + + val timed = patch(allDay, Tasks.IS_ALLDAY to 0, Tasks.DUE to millis(Instant.parse("2026-10-20T15:00:00Z"))) + + assertThat(timed.timezone).isEqualTo("Europe/Berlin") + } + + @Test + fun `clearing every date clears the zone`() { + assertThat(patch(task.copy(due = now, timezone = "UTC"), Tasks.DUE to null).timezone).isNull() + } + + @Test + fun `exception and extra dates must be iCalendar values of one kind`() { + val series = task.copy(due = now, rrule = "FREQ=DAILY", timezone = "UTC") + + assertThat(patch(series, Tasks.EXDATE to "20261016T100000Z, 20261017T100000Z").exdate) + .isEqualTo("20261016T100000Z,20261017T100000Z") + assertThat(patch(series, Tasks.RDATE to "20261030").rdate).isEqualTo("20261030") + assertThrows { patch(series, Tasks.EXDATE to "tomorrow") } + assertThrows { patch(series, Tasks.EXDATE to "20261016,20261017T100000Z") } + assertThrows { patch(series, Tasks.RDATE to "20261016T100000Z,20261017T100000") } + } + + @Test + fun `stored dates written back as synced are kept`() { + val series = task.copy(due = now, rrule = "FREQ=DAILY", rdate = "20261016T100000Z/PT1H", exdate = "20261016,20261017T100000Z") + + val edited = patch(series, Tasks.TITLE to "New", Tasks.RDATE to series.rdate, Tasks.EXDATE to series.exdate) + + assertThat(edited.rdate).isEqualTo(series.rdate) + assertThat(edited.exdate).isEqualTo(series.exdate) + assertThat(ProviderTaskWriter.changedColumns(series, mapOf(Tasks.RDATE to series.rdate), "UTC")).isEmpty() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriterTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriterTest.kt index b64fb59..8c8d523 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriterTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriterTest.kt @@ -3,6 +3,7 @@ package de.jeanlucmakiola.agendula.data.tasks.room import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.agendula.domain.Priority import de.jeanlucmakiola.agendula.domain.TaskForm +import de.jeanlucmakiola.agendula.domain.TaskReminder import de.jeanlucmakiola.agendula.domain.TaskStatus import org.junit.jupiter.api.Test import kotlin.time.Instant @@ -53,6 +54,17 @@ class TaskFormWriterTest { assertThat(entity.isDirty).isTrue() } + @Test + fun `an empty set the editor chose turns reminders off, any other keeps the flag`() { + val due = Instant.parse("2026-02-01T10:00:00Z") + val chosen = form(due = due).copy(remindersChosen = true) + assertThat(TaskFormWriter.apply(task(), chosen, NOW, ZONE).remindersOff).isTrue() + val withOne = chosen.copy(reminders = listOf(TaskReminder(15))) + assertThat(TaskFormWriter.apply(task().copy(remindersOff = true), withOne, NOW, ZONE).remindersOff).isFalse() + assertThat(TaskFormWriter.apply(task().copy(remindersOff = true), form(due = due), NOW, ZONE).remindersOff).isTrue() + assertThat(TaskFormWriter.apply(task(), form(due = due), NOW, ZONE).remindersOff).isFalse() + } + @Test fun `tags are trimmed and blanks dropped`() { val entity = TaskFormWriter.apply(task(), form(tags = listOf(" Work ", "", " ", "Home")), NOW, ZONE) diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskFormTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskFormTest.kt index fb42d1f..736e85c 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskFormTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskFormTest.kt @@ -31,9 +31,15 @@ class TaskFormTest { } @Test - fun `a reminder requires a due date`() { - val errors = TaskForm(title = "x", listId = 1, reminders = listOf(10)).validate() - assertThat(errors).contains(TaskFormError.REMINDER_WITHOUT_DUE) + fun `a reminder requires a start or due date`() { + val errors = TaskForm(title = "x", listId = 1, reminders = listOf(TaskReminder(10))).validate() + assertThat(errors).contains(TaskFormError.REMINDER_WITHOUT_DATE) + } + + @Test + fun `a start alone is enough for a reminder`() { + val errors = TaskForm(title = "x", listId = 1, start = Instant.fromEpochSeconds(0), reminders = listOf(TaskReminder(10))).validate() + assertThat(errors).doesNotContain(TaskFormError.REMINDER_WITHOUT_DATE) } @Test diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskReminderTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskReminderTest.kt new file mode 100644 index 0000000..64e3ba8 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskReminderTest.kt @@ -0,0 +1,60 @@ +package de.jeanlucmakiola.agendula.domain + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class TaskReminderTest { + + @Test + fun `whole days keep the device's all-day time`() { + assertThat(TaskReminder(0).allDay).isEqualTo(AllDayReminder(0, null)) + assertThat(TaskReminder(2 * 1_440).allDay).isEqualTo(AllDayReminder(2, null)) + } + + @Test + fun `anything else names a time of day`() { + assertThat(TaskReminder(-9 * 60).allDay).isEqualTo(AllDayReminder(0, 9 * 60)) + assertThat(TaskReminder(7 * 60).allDay).isEqualTo(AllDayReminder(1, 17 * 60)) + assertThat(TaskReminder(15).allDay).isEqualTo(AllDayReminder(1, 23 * 60 + 45)) + } + + @Test + fun `a day and a time round-trip`() { + listOf(0 to 9 * 60, 1 to 17 * 60, 7 to 30, 3 to null).forEach { (days, minute) -> + assertThat(TaskReminder.allDay(days, minute).allDay).isEqualTo(AllDayReminder(days, minute)) + } + } + + @Test + fun `a lead past the day is clamped to its last minute`() { + assertThat(TaskReminder(-1_440).allDay).isEqualTo(AllDayReminder(0, 1_439)) + } + + @Test + fun `midnight is a minute past it`() { + assertThat(TaskReminder.allDay(1, 0).allDay).isEqualTo(AllDayReminder(1, 1)) + assertThat(TaskReminder.allDay(1, null).allDay).isEqualTo(AllDayReminder(1, null)) + } + + @Test + fun `a default lead keeps its anchor whatever its lead`() { + val reminders = listOf(TaskReminder(-540, fromStart = true), TaskReminder(1_440, fromStart = true), TaskReminder(-540)) + assertThat(reminders.map { it.asReminderLead().asTaskReminder() }).isEqualTo(reminders) + } + + @Test + fun `whole days round to the nearest`() { + assertThat(TaskReminder(60).inWholeDays()).isEqualTo(TaskReminder(0)) + assertThat(TaskReminder(23 * 60, fromStart = true).inWholeDays()).isEqualTo(TaskReminder(1_440, fromStart = true)) + assertThat(TaskReminder(-540).inWholeDays()).isEqualTo(TaskReminder(0)) + } + + @Test + fun `all-day reminders are listed in the order they ring`() { + val eightPm = TaskReminder(0) + val nine = TaskReminder.allDay(0, 9 * 60) + val dayBefore = TaskReminder(1_440) + assertThat(listOf(eightPm, nine, dayBefore).sortedWith(taskReminderDisplayOrder(true, 20 * 60))) + .containsExactly(dayBefore, nine, eightPm).inOrder() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/TestTasks.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/TestTasks.kt index ad92ce0..54119ee 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/domain/TestTasks.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/TestTasks.kt @@ -14,6 +14,8 @@ fun testTask( completedAt: Instant? = null, parentId: Long? = null, tags: List = emptyList(), + start: Instant? = null, + created: Instant? = null, ): Task = Task( taskId = id, listId = listId, @@ -24,7 +26,7 @@ fun testTask( priority = priority, status = status, percentComplete = null, - start = null, + start = start, due = due, isAllDay = isAllDay, timeZone = null, @@ -37,7 +39,7 @@ fun testTask( isRecurring = false, occurrenceStart = null, distanceFromCurrent = 0, - created = null, + created = created, lastModified = null, tags = tags, ) diff --git a/app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/CollectionSyncerTest.kt b/app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/CollectionSyncerTest.kt index 04ea3f6..d49e2bc 100644 --- a/app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/CollectionSyncerTest.kt +++ b/app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/CollectionSyncerTest.kt @@ -1048,20 +1048,33 @@ class CollectionSyncerTest { assertThat(store.rows.single().remindersOff).isFalse() } - @Test fun `an alarm coming back as we sent it keeps the lead the wire could not carry`() { - // A 15-minute lead on an all-day task goes out as "on the day". + @Test fun `an all-day reminder at a time of its own round-trips from midnight`() { + // 09:00 on the day. store.rows += task(id = 1, uid = "a", title = "Pay rent") .copy(due = Instant.parse("2026-03-05T00:00:00Z"), isAllDay = true, isDirty = true) - store.alarms += alarm(1, 15, uid = "a-1") + store.alarms += alarm(1, -9 * 60, uid = "a-1") + sync() + val sent = remote.resources.values.single() + assertThat(sent.body).contains("TRIGGER;RELATED=END:PT9H") + + sent.eTag = de.jeanlucmakiola.caldav.ETag("e-2", weak = false) + sync() + + assertThat(store.alarms.single().minutesBefore).isEqualTo(-9 * 60) + } + + @Test fun `an alarm coming back as we sent it keeps the lead the wire could not carry`() { + // A lead past due is clamped to "at due" on the way out. + store.rows += task(id = 1, uid = "a", title = "Pay rent").copy(due = due, isDirty = true) + store.alarms += alarm(1, -15, uid = "a-1") sync() val sent = remote.resources.values.single() assertThat(sent.body).contains("TRIGGER;RELATED=END:PT0S") - // The server hands it back under a new ETag, unchanged. sent.eTag = de.jeanlucmakiola.caldav.ETag("e-2", weak = false) sync() - assertThat(store.alarms.single().minutesBefore).isEqualTo(15) + assertThat(store.alarms.single().minutesBefore).isEqualTo(-15) } @Test fun `a before-start reminder on a task without a start survives the round trip`() { 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 34fb130..ee123ff 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 @@ -218,6 +218,226 @@ class AddAccountViewModelTest { // The input survives, so the user can correct it rather than retype it. assertThat(step.input).isEqualTo("https://example.com/") } + + @Test + fun `an email that finds no server asks for the server address instead`() = runTest(dispatcher) { + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NotCalDav( + CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER, + "405 on mailbox.org", + ) + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(listOf("dav.mailbox.org")) + + 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.input).isEmpty() + assertThat(step.error).isNull() + + vm.onAddressChanged("https://dav.mailbox.org/") + vm.onAddressSubmitted() + advanceUntilIdle() + + // The email the user already gave is the username, not retyped. + val credentials = vm.state.value.step as AddAccountStep.EnterCredentials + assertThat(credentials.username).isEqualTo("me@mailbox.org") + } + + @Test + fun `back from the server fallback returns to the email`() = runTest(dispatcher) { + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NotCalDav( + CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER, + "405 on mailbox.org", + ) + + val vm = viewModel() + vm.onProviderChosen(ProviderChoice.Service(CalDavProvider.MAILBOX_ORG)) + vm.onAddressChanged("me@mailbox.org") + vm.onAddressSubmitted() + advanceUntilIdle() + + assertThat(vm.onBackWithin()).isTrue() + val step = vm.state.value.step as AddAccountStep.EnterAddress + assertThat(step.manualServer).isFalse() + assertThat(step.input).isEqualTo("me@mailbox.org") + } + + @Test + fun `a failure that is not a missing server keeps its error on the email`() = runTest(dispatcher) { + for (cause in listOf( + CalDavDiscovery.Outcome.Cause.NOT_AN_ADDRESS, + CalDavDiscovery.Outcome.Cause.SERVER_ERROR, + )) { + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.Failed(cause, "$cause") + 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).isFalse() + assertThat(step.error).isEqualTo(cause) + assertThat(step.input).isEqualTo("me@mailbox.org") + } + } + + @Test + fun `an email nothing answered for asks for the server and keeps the error`() = runTest(dispatcher) { + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.Failed( + CalDavDiscovery.Outcome.Cause.UNREACHABLE, + "no route", + ) + + val vm = viewModel() + vm.onProviderChosen(ProviderChoice.OtherServer) + vm.onAddressChanged("me@mail-only.example") + vm.onAddressSubmitted() + advanceUntilIdle() + + val step = vm.state.value.step as AddAccountStep.EnterAddress + assertThat(step.manualServer).isTrue() + assertThat(step.input).isEmpty() + assertThat(step.error).isEqualTo(CalDavDiscovery.Outcome.Cause.UNREACHABLE) + } + + @Test + fun `an email that asked for a password first still reaches the server field`() = runTest(dispatcher) { + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(listOf("example.com")) + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NotCalDav( + CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER, + "405", + ) + + val vm = viewModel() + vm.onProviderChosen(ProviderChoice.OtherServer) + vm.onAddressChanged("me@example.com") + vm.onAddressSubmitted() + advanceUntilIdle() + vm.onUsernameChanged("me@example.com") + vm.onPasswordChanged("secret") + vm.onCredentialsSubmitted() + advanceUntilIdle() + + val step = vm.state.value.step as AddAccountStep.EnterAddress + assertThat(step.manualServer).isTrue() + assertThat(step.error).isNull() + } + + @Test + fun `the guidance follows the field through the fallback`() = runTest(dispatcher) { + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NotCalDav( + CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER, + "405", + ) + + val vm = viewModel() + vm.onProviderChosen(ProviderChoice.OtherServer) + vm.onAddressChanged("me@fastmail.com") + vm.onAddressSubmitted() + advanceUntilIdle() + assertThat(vm.state.value.quirk).isNull() + + vm.onAddressChanged("https://caldav.icloud.com/") + assertThat(vm.onBackWithin()).isTrue() + assertThat(vm.state.value.quirk).isEqualTo(ServerQuirk.FASTMAIL_APP_PASSWORD) + } + + @Test + fun `a different email typed into the server field becomes the username`() = runTest(dispatcher) { + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NotCalDav( + CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER, + "405", + ) + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(listOf("new.example")) + + val vm = viewModel() + vm.onProviderChosen(ProviderChoice.OtherServer) + vm.onAddressChanged("a@old.example") + vm.onAddressSubmitted() + advanceUntilIdle() + vm.onAddressChanged("b@new.example") + vm.onAddressSubmitted() + advanceUntilIdle() + + val credentials = vm.state.value.step as AddAccountStep.EnterCredentials + assertThat(credentials.username).isEqualTo("b@new.example") + } + + @Test + fun `a mailto address falls back without its prefix`() = runTest(dispatcher) { + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NotCalDav( + CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER, + "405", + ) + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(listOf("dav.example.com")) + + val vm = viewModel() + vm.onProviderChosen(ProviderChoice.OtherServer) + vm.onAddressChanged("mailto:me@example.com") + vm.onAddressSubmitted() + advanceUntilIdle() + vm.onAddressChanged("https://dav.example.com/") + vm.onAddressSubmitted() + advanceUntilIdle() + + val credentials = vm.state.value.step as AddAccountStep.EnterCredentials + assertThat(credentials.username).isEqualTo("me@example.com") + vm.onBackWithin() + vm.onBackWithin() + assertThat((vm.state.value.step as AddAccountStep.EnterAddress).input).isEqualTo("me@example.com") + } + + @Test + fun `back from the fallback keeps a re-authentication's username`() = runTest(dispatcher) { + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NotCalDav( + CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER, + "405", + ) + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(listOf("dav.mailbox.org")) + + val vm = viewModel() + vm.startReauthentication(1L, "https://dav.mailbox.org/caldav/me/", "me@mailbox.org") + vm.onAddressChanged("me@mailbox.org") + vm.onAddressSubmitted() + advanceUntilIdle() + assertThat(vm.onBackWithin()).isTrue() + vm.onAddressSubmitted() + advanceUntilIdle() + + val credentials = vm.state.value.step as AddAccountStep.EnterCredentials + assertThat(credentials.username).isEqualTo("me@mailbox.org") + } + + @Test + fun `a server address that fails stays on the server field`() = runTest(dispatcher) { + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NotCalDav( + CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER, + "405", + ) + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NotCalDav( + CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER, + "405 again", + ) + + val vm = viewModel() + vm.onProviderChosen(ProviderChoice.Service(CalDavProvider.MAILBOX_ORG)) + vm.onAddressChanged("me@mailbox.org") + vm.onAddressSubmitted() + advanceUntilIdle() + vm.onAddressChanged("https://mailbox.org/") + vm.onAddressSubmitted() + advanceUntilIdle() + + val step = vm.state.value.step as AddAccountStep.EnterAddress + assertThat(step.manualServer).isTrue() + assertThat(step.error).isEqualTo(CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER) + assertThat(step.input).isEqualTo("https://mailbox.org/") + } } @Nested diff --git a/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/DnsAnswers.kt b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/DnsAnswers.kt new file mode 100644 index 0000000..9ad4f96 --- /dev/null +++ b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/DnsAnswers.kt @@ -0,0 +1,29 @@ +package de.jeanlucmakiola.caldav + +import org.xbill.DNS.Message +import org.xbill.DNS.SRVRecord +import org.xbill.DNS.Section +import org.xbill.DNS.TXTRecord +import org.xbill.DNS.Type + +/** + * Reads SRV and TXT records out of a raw DNS answer. + * + * The lookup itself is the platform's job — on Android the system resolver, + * which follows the current network and honours Private DNS — so this only + * parses the wire format it hands back. A malformed answer reads as no records. + */ +object DnsAnswers { + + const val TYPE_SRV = Type.SRV + const val TYPE_TXT = Type.TXT + + fun srv(answer: ByteArray): List = records(answer) + .map { SrvRecord(it.priority, it.weight, it.port, it.target.toString(true)) } + + fun txt(answer: ByteArray): List = records(answer).flatMap { it.strings } + + private inline fun records(answer: ByteArray): List = runCatching { + Message(answer).getSection(Section.ANSWER).filterIsInstance() + }.getOrDefault(emptyList()) +} diff --git a/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/DnsJavaResolver.kt b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/DnsJavaResolver.kt deleted file mode 100644 index 3824dd1..0000000 --- a/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/DnsJavaResolver.kt +++ /dev/null @@ -1,34 +0,0 @@ -package de.jeanlucmakiola.caldav - -import org.xbill.DNS.Lookup -import org.xbill.DNS.SRVRecord -import org.xbill.DNS.TXTRecord -import org.xbill.DNS.Type - -/** - * SRV/TXT lookups over dnsjava. - * - * Android exposes no usable SRV API — `DnsResolver` arrived in API 29 but is - * callback-only and does not help with the `TXT path=` half — and JNDI's DNS - * provider does not exist on Android at all. dnsjava is what DAVx5 uses. - * - * A lookup that fails returns an empty list rather than throwing: DNS being - * unavailable means "no SRV record", which is an ordinary and common answer, and - * the well-known ladder still has rungs left. - */ -class DnsJavaResolver : DnsResolver { - - override fun srv(name: String): List = runCatching { - Lookup(name, Type.SRV).run() - .orEmpty() - .filterIsInstance() - .map { SrvRecord(it.priority, it.weight, it.port, it.target.toString(true)) } - }.getOrDefault(emptyList()) - - override fun txt(name: String): List = runCatching { - Lookup(name, Type.TXT).run() - .orEmpty() - .filterIsInstance() - .flatMap { it.strings } - }.getOrDefault(emptyList()) -} diff --git a/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/ServiceDiscovery.kt b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/ServiceDiscovery.kt index 1a5951f..4ed5a22 100644 --- a/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/ServiceDiscovery.kt +++ b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/ServiceDiscovery.kt @@ -11,18 +11,20 @@ data class SrvRecord( val target: String, ) +/** The SRV and TXT records published under one name. */ +data class DnsRecords(val srv: List, val txt: List) + /** * DNS lookups for RFC 6764. Behind an interface so the pipeline is testable * without a network, and so the resolver can be swapped per platform. */ interface DnsResolver { - fun srv(name: String): List - fun txt(name: String): List + /** The SRV and TXT records for [name], looked up together. */ + fun lookup(name: String): DnsRecords /** For the base-URL path, where DNS is never consulted. */ object None : DnsResolver { - override fun srv(name: String) = emptyList() - override fun txt(name: String) = emptyList() + override fun lookup(name: String) = DnsRecords(emptyList(), emptyList()) } } @@ -112,14 +114,16 @@ object ServiceDiscovery { val domain = domainOf(trimmed) ?: return emptyList() val candidates = mutableListOf() - val srv = dns.srv("$SRV_SERVICE.$domain") + val records = dns.lookup("$SRV_SERVICE.$domain") + val srv = records.srv .filterNot { it.target == "." || it.target.isEmpty() || it.port == 0 } - // DNS names are case-insensitive and dnsjava returns the wire case, so + // DNS names are case-insensitive and answers keep the wire case, so // "Calendar.Google.com." would otherwise walk straight past the guard. .filterNot { it.target.trimEnd('.').lowercase() in SRV_DEAD_ENDS } // ⚠️ RFC 2782 does not require a target to live inside the domain it - // was queried for, and this ladder is walked over plain UDP DNS with - // no DNSSEC — then walked *again* after a 401, with the + // was queried for, and the answer is not DNSSEC-validated (Private + // DNS encrypts the hop, it does not authenticate the zone) — and + // this ladder is walked *again* after a 401, with the // authenticated client. An out-of-domain target is also one the // credential would never be offered to, since CalDavHttp scopes it // to the typed address's registrable domain, so following it can @@ -131,7 +135,7 @@ object ServiceDiscovery { // The TXT path applies to the SRV target, and to the bare domain when // there is no SRV record — that is the GMX/Web.de shape. - val txtPath = dns.txt("$SRV_SERVICE.$domain") + val txtPath = records.txt .firstNotNullOfOrNull { record -> record.split(' ', ';') .firstOrNull { it.startsWith("path=", ignoreCase = true) } @@ -205,6 +209,19 @@ object ServiceDiscovery { return input.toHttpUrlOrNull() } + /** + * The email address [input] names, without a `mailto:` prefix, or null when + * it is a URL or a bare domain — the same split [candidatesFor] makes. + */ + fun emailOf(input: String): String? { + val trimmed = input.trim() + if (asBaseUrl(trimmed) != null) return null + val email = trimmed.removePrefix("mailto:").removePrefix("MAILTO:") + return email.takeIf { + it.substringBeforeLast('@', "").isNotEmpty() && domainOf(it) != null + } + } + /** The domain of an email address, a `mailto:` URI, or a bare domain. */ internal fun domainOf(input: String): String? { val withoutScheme = input.removePrefix("mailto:").removePrefix("MAILTO:") diff --git a/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/DnsAnswersTest.kt b/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/DnsAnswersTest.kt new file mode 100644 index 0000000..6aa8d07 --- /dev/null +++ b/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/DnsAnswersTest.kt @@ -0,0 +1,44 @@ +package de.jeanlucmakiola.caldav + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.xbill.DNS.DClass +import org.xbill.DNS.Message +import org.xbill.DNS.Name +import org.xbill.DNS.Record +import org.xbill.DNS.SRVRecord +import org.xbill.DNS.Section +import org.xbill.DNS.TXTRecord +import org.xbill.DNS.Type + +class DnsAnswersTest { + + private val name = Name.fromString("_caldavs._tcp.mailbox.org.") + + private fun answer(type: Int, vararg records: Record): ByteArray { + val message = Message.newQuery(Record.newRecord(name, type, DClass.IN)) + records.forEach { message.addRecord(it, Section.ANSWER) } + return message.toWire() + } + + @Test + fun `reads SRV records`() { + val bytes = answer( + Type.SRV, + SRVRecord(name, DClass.IN, 300, 10, 1, 443, Name.fromString("dav.mailbox.org.")), + ) + assertThat(DnsAnswers.srv(bytes)).containsExactly(SrvRecord(10, 1, 443, "dav.mailbox.org")) + } + + @Test + fun `reads TXT strings`() { + val bytes = answer(Type.TXT, TXTRecord(name, DClass.IN, 300, "path=/")) + assertThat(DnsAnswers.txt(bytes)).containsExactly("path=/") + } + + @Test + fun `an empty or malformed answer has no records`() { + assertThat(DnsAnswers.srv(answer(Type.SRV))).isEmpty() + assertThat(DnsAnswers.srv(byteArrayOf(1, 2, 3))).isEmpty() + } +} diff --git a/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/ServiceDiscoveryTest.kt b/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/ServiceDiscoveryTest.kt index 3c8c07e..adcc53c 100644 --- a/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/ServiceDiscoveryTest.kt +++ b/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/ServiceDiscoveryTest.kt @@ -14,8 +14,7 @@ class ServiceDiscoveryTest { val srv: Map> = emptyMap(), val txt: Map> = emptyMap(), ) : DnsResolver { - override fun srv(name: String) = srv[name].orEmpty() - override fun txt(name: String) = txt[name].orEmpty() + override fun lookup(name: String) = DnsRecords(srv[name].orEmpty(), txt[name].orEmpty()) } private fun urls(input: String, dns: DnsResolver = DnsResolver.None) = @@ -153,6 +152,15 @@ class ServiceDiscoveryTest { assertThat(ServiceDiscovery.domainOf("example.com")).isEqualTo("example.com") } + @Test + fun `an email is told apart from a URL and loses its mailto prefix`() { + assertThat(ServiceDiscovery.emailOf("mailto:me@example.com")).isEqualTo("me@example.com") + assertThat(ServiceDiscovery.emailOf(" me@example.com ")).isEqualTo("me@example.com") + assertThat(ServiceDiscovery.emailOf("https://me@example.com/")).isNull() + assertThat(ServiceDiscovery.emailOf("example.com")).isNull() + assertThat(ServiceDiscovery.emailOf("@example.com")).isNull() + } + @Test fun `something that is neither an address nor a URL yields nothing`() { assertThat(ServiceDiscovery.candidatesFor("hello")).isEmpty() diff --git a/docs/CONTENT_PROVIDER.md b/docs/CONTENT_PROVIDER.md new file mode 100644 index 0000000..9209489 --- /dev/null +++ b/docs/CONTENT_PROVIDER.md @@ -0,0 +1,119 @@ +# Using Agendula's tasks from another app + +When Agendula keeps tasks in its own database (the default storage mode), it +publishes them through a `ContentProvider` that follows the +[OpenTasks contract](https://github.com/dmfs/opentasks/blob/master/opentasks-contract/src/main/java/org/dmfs/tasks/contract/TaskContract.java). +An app that already works with OpenTasks or tasks.org can work with Agendula by +using a different authority and permissions. + +| | | +|---|---| +| Authority | `de.jeanlucmakiola.agendula.tasks` (offline build: `de.jeanlucmakiola.agendula.offline.tasks`) | +| Read permission | `de.jeanlucmakiola.agendula.permission.READ_TASKS` (offline build: `de.jeanlucmakiola.agendula.offline.permission.READ_TASKS`) | +| Write permission | `de.jeanlucmakiola.agendula.permission.WRITE_TASKS` (offline build: `de.jeanlucmakiola.agendula.offline.permission.WRITE_TASKS`) | +| Protection level | `dangerous`: request them at runtime | +| Package visibility | add `` to your `` | + +Both names are derived from the installed package name, so every build has its +own and they can be installed side by side: the authority is +`.tasks` and the permissions are `.permission.READ_TASKS` and +`.permission.WRITE_TASKS`. + +| Build | Package | +|---|---| +| Release | `de.jeanlucmakiola.agendula` | +| Release, offline | `de.jeanlucmakiola.agendula.offline` | +| Debug | `de.jeanlucmakiola.agendula.debug` | +| Debug, offline | `de.jeanlucmakiola.agendula.offline.debug` | +| Release test | `de.jeanlucmakiola.agendula.releasetest` | +| Release test, offline | `de.jeanlucmakiola.agendula.offline.releasetest` | + +## Tables + +| URI | Rows | +|---|---| +| `content:///tasklists` | one per list | +| `content:///tasks` | one per task; a modified occurrence of a recurring task is its own row with `original_instance_id` / `original_instance_time` set | +| `content:///instances` | one per occurrence: recurring tasks are expanded up to one year back and two years ahead, and modified occurrences replace the ones they override | +| `content:///properties` | reminders (`vnd.android.cursor.item/alarm`) and tags (`vnd.android.cursor.item/category`) | + +Each table also accepts `/`. Column names, value encodings (epoch millis, +all-day dates as UTC midnight, `status`, `priority`, `class`) and MIME types +are the OpenTasks ones. `selection`, `selectionArgs` and `sortOrder` work as +they do against OpenTasks. + +## Writing + +Writes need the write permission and go through the same code as edits made in +Agendula itself: a changed task is marked for the next CalDAV sync, reminders +are re-armed, and widgets refresh. + +| Table | insert | update | delete | +|---|---|---|---| +| `tasklists` | no | no | no | +| `tasks` | yes | yes | yes | +| `instances` | no | see below | one occurrence | +| `properties` | alarms and tags | alarms and tags | alarms and tags | + +`update` and `delete` take a `selection` like a query does, and it matches the +rows you can read. `insert` returns the new row's URI. `applyBatch` runs in one +transaction: if any operation fails, none of them is applied. A write that +changes nothing does not mark the task for sync. + +**Columns.** A task write may set `list_id`, `_uid` (on insert only), `title`, +`description`, `location`, `url`, `priority`, `class`, `status`, +`percent_complete`, `completed`, `task_color`, `dtstart`, `due`, `duration`, +`is_allday`, `tz`, `rrule`, `rdate`, `exdate`, `parent_id` and `sorting`. Other +columns the table publishes are ignored, so you can write back a row you read. +A column the table does not have is rejected with `IllegalArgumentException`. +Agendula's sync state (the server's URL and ETag, the iCalendar `SEQUENCE`, and +properties it does not understand) is not published and cannot be written. A +timed task without a `tz` is floating and stays floating. A task inserted +without a `tz` is floating, as in OpenTasks; an existing task that first gets a +date takes the device's zone. `rdate` and `exdate` take comma-separated +iCalendar values of one kind: all dates, all UTC date-times or all local +date-times. A value written back exactly as read is kept, whatever its shape. + +**Status.** `status`, `percent_complete` and `completed` stay consistent the way +OpenTasks keeps them. Completing a task sets 100 % and a completion time, +progress between 1 and 99 sets "in process", and a completion time alone +completes the task. When you write back a row you read, the column you changed +decides; the others it carries unchanged do not. + +**Recurring tasks.** Writing `status`, `percent_complete` or `completed` to an +`instances` row completes, cancels or reopens that one occurrence; partial +progress is rejected. Other content columns written to an instance change the +whole series, and that occurrence too if it had been modified on its own. +Changing a time or recurrence column (`dtstart`, `due`, `duration`, +`is_allday`, `tz`, `rrule`, `rdate`, `exdate`) is rejected on an instance of a +recurring task, so move a series through `tasks`; writing those columns back +unchanged, as in a row you read, is fine. Moving a series' start or changing its +rule drops the occurrences that had been modified on their own, as it does in +the app. Deleting an `instances` row deletes that one occurrence. + +**Subtasks.** A subtask has to be in its parent's list. Moving a task to another +list moves its subtasks with it, and deleting a task deletes them. A task in a +synced list stays as a hidden tombstone until the next sync removes it from the +server. + +**Rejected writes** throw `IllegalArgumentException` for a bad value, a missing +list or parent, or a parent in another list. They throw +`UnsupportedOperationException` for lists and for tasks in a list the server +has made read-only, and `IllegalStateException` while Agendula works on top of +an external provider or is still starting. `list_access_level` does not tell +you which lists are read-only: OpenTasks reserves it and asks providers to +publish `0`. + +## Behaviour + +- **Change notifications.** Register a `ContentObserver` on + `content://` with `notifyForDescendants = true`. It fires when + tasks change or when the user switches storage mode. +- **External storage mode.** When the user has Agendula work on top of + OpenTasks or tasks.org, every table is empty and writes are refused. Use that + provider directly. +- **Ids are stable.** A task keeps its `_id`. An instance's `_id` is built + from its series and its original time, and a property's from its alarm or + its task and tag, so they stay the same across reads and within a batch for + as long as that occurrence, alarm or tag exists. `distance_from_current` + still moves with the clock, and observers are notified hourly when it does. diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index a09b504..66368b8 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -53,8 +53,10 @@ All of this is verifiable in the ## 3. Where your tasks live — your choice - **On your device (the default)** — your task lists, tasks and reminders are - kept in Agendula's own database inside the app's private storage. Nothing is - published to other apps, and uninstalling the app removes it. + kept in Agendula's own database inside the app's private storage, and + uninstalling the app removes it. Another app can read these tasks only if you + grant it Agendula's "read Agendula tasks" permission, and change them only if + you grant it "add, change and delete Agendula tasks" (see section 9). - **In a tasks provider you already use** — OpenTasks or tasks.org. Agendula then reads and writes that app's task database through Android's provider mechanism, after you grant its read/write permission. Whatever already @@ -97,8 +99,8 @@ nominated. Data is encrypted in transit. When you type a server address or an email domain, Agendula follows the standard discovery procedure (RFC 6764): a DNS lookup for the `_caldavs._tcp` service record of that domain, then `/.well-known/caldav` on the host. The DNS -query goes to whichever resolver your device or network uses, and the requests -go to the domain you typed — no directory of servers is consulted and no lookup +query goes through Android's own resolver, so it uses your device's Private DNS +setting when one is on, and the requests go to the domain you typed — no directory of servers is consulted and no lookup is sent to the developer. ### Signing in to a Nextcloud @@ -210,8 +212,20 @@ source: used for scheduled background work (WorkManager); on older Android versions it needs them to run an expedited sync. -Agendula publishes no content provider of its own and declares no permissions -that other apps could request. +Agendula declares two permissions that other apps can request. Android asks you +before granting either, and you can revoke them at any time in the system +settings. + +- `de.jeanlucmakiola.agendula.permission.READ_TASKS`: an app that holds it can + read your task lists, tasks, reminders and tags from Agendula's own database. +- `de.jeanlucmakiola.agendula.permission.WRITE_TASKS`: an app that holds it can + add, change, complete and delete those tasks, reminders and tags. It cannot + change or delete task lists. Its changes sync to your CalDAV server like your + own edits. + +Neither gives access to anything while Agendula works on top of an external +tasks provider. What an app does with your tasks is governed by its own privacy +policy. ## 10. Distribution channels diff --git a/fastlane/metadata/android/ar/changelogs/1020099.txt b/fastlane/metadata/android/ar/changelogs/1020099.txt new file mode 100644 index 0000000..c756ac6 --- /dev/null +++ b/fastlane/metadata/android/ar/changelogs/1020099.txt @@ -0,0 +1,11 @@ +### جديد +- يمكن أن يُحسب التذكير من بدء المهمة أو من موعد الاستحقاق. +- يمكن لمهام اليوم الكامل أن تحمل تذكيرات في أوقات محددة. +- يمكن للتطبيقات الأخرى قراءة مهامك المحلية وتعديلها عبر OpenTasks، بإذنك. + +### تغييرات +- تُزامَن التذكيرات كـ VALARM، فترنّ أيضًا في تذكيرات Apple وThunderbird وtasks.org. + +### إصلاحات +- تعمل الأدوات مجددًا بعد إنجاز المهام. +- عاد تسجيل الدخول بالبريد الإلكتروني يعمل لدى mailbox.org وPosteo. diff --git a/fastlane/metadata/android/ar/full_description.txt b/fastlane/metadata/android/ar/full_description.txt new file mode 100644 index 0000000..17bd7e7 --- /dev/null +++ b/fastlane/metadata/android/ar/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/ar/short_description.txt b/fastlane/metadata/android/ar/short_description.txt new file mode 100644 index 0000000..5099ce6 --- /dev/null +++ b/fastlane/metadata/android/ar/short_description.txt @@ -0,0 +1 @@ +تطبيق مهام بتصميم Material 3 Expressive مع مزامنة CalDAV. diff --git a/fastlane/metadata/android/ar/title.txt b/fastlane/metadata/android/ar/title.txt new file mode 100644 index 0000000..5c368ac --- /dev/null +++ b/fastlane/metadata/android/ar/title.txt @@ -0,0 +1 @@ +Agendula: Tasks diff --git a/fastlane/metadata/android/cs-CZ/changelogs/1020099.txt b/fastlane/metadata/android/cs-CZ/changelogs/1020099.txt new file mode 100644 index 0000000..4199987 --- /dev/null +++ b/fastlane/metadata/android/cs-CZ/changelogs/1020099.txt @@ -0,0 +1,11 @@ +### Nové +- Připomínky se mohou počítat od začátku úkolu i od termínu. +- Celodenní úkoly mohou mít připomínky v pevný čas. +- Jiné aplikace mohou s vaším svolením číst a upravovat místní úkoly přes OpenTasks. + +### Změny +- Připomínky se synchronizují jako VALARM a zvoní i v Připomínkách Apple, Thunderbirdu a tasks.org. + +### Opravy +- Widgety opět fungují po odškrtnutí úkolu. +- Přihlášení e-mailem u mailbox.org a Posteo opět funguje. diff --git a/fastlane/metadata/android/cs-CZ/full_description.txt b/fastlane/metadata/android/cs-CZ/full_description.txt new file mode 100644 index 0000000..4406dc4 --- /dev/null +++ b/fastlane/metadata/android/cs-CZ/full_description.txt @@ -0,0 +1,11 @@ +Agendula je moderní open-source aplikace na úkoly pro Android. + +Synchronizujte úkoly přes CalDAV: přidejte účet Nextcloud, Radicale, Baïkal nebo jiný CalDAV účet a vyberte, které seznamy úkolů se mají synchronizovat. Nebo nechte vše v telefonu. Agendula si úkoly ukládá sama, bez účtu a bez instalace čehokoli dalšího. + +Už používáte OpenTasks nebo tasks.org? Nasměrujte Agendulu na tuto aplikaci, aby se úkoly synchronizované přes DAVx5 a podobně zobrazily tak, jak jsou, a kdykoli si je zkopírujte do vlastního úložiště Agenduly v Nastavení → Úložiště. + +Co umí: seznamy spravované přímo v aplikaci, termíny a data začátku, celodenní úkoly, podúkoly, priority, průběh, opakované úkoly s možností upravit jediný výskyt, více připomínek na úkol s odložením, import a export iCalendar, widget na plochu, zkratky spouštěče a dlaždici Rychlého nastavení. + +Design je skrz naskrz Material 3 Expressive, s dynamickými barvami a expresivními animacemi a tvary. + +Soukromí: žádná telemetrie, žádná analytika, žádné reklamy. Agendula se připojuje k internetu jen kvůli synchronizaci s CalDAV účty, které sami přidáte. Bez účtu vaše úkoly zařízení neopustí, pokud je sami neexportujete. diff --git a/fastlane/metadata/android/cs-CZ/short_description.txt b/fastlane/metadata/android/cs-CZ/short_description.txt new file mode 100644 index 0000000..c4008d7 --- /dev/null +++ b/fastlane/metadata/android/cs-CZ/short_description.txt @@ -0,0 +1 @@ +Úkolníček v Material 3 Expressive se synchronizací CalDAV. diff --git a/fastlane/metadata/android/cs-CZ/title.txt b/fastlane/metadata/android/cs-CZ/title.txt new file mode 100644 index 0000000..5c368ac --- /dev/null +++ b/fastlane/metadata/android/cs-CZ/title.txt @@ -0,0 +1 @@ +Agendula: Tasks diff --git a/fastlane/metadata/android/de-DE/changelogs/1020099.txt b/fastlane/metadata/android/de-DE/changelogs/1020099.txt new file mode 100644 index 0000000..b9b4d85 --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/1020099.txt @@ -0,0 +1,11 @@ +### Neu +- Erinnerungen können ab dem Start oder ab der Fälligkeit zählen. +- Ganztägige Aufgaben können Erinnerungen zu festen Uhrzeiten haben. +- Andere Apps können lokale Aufgaben über OpenTasks lesen und bearbeiten, wenn du es erlaubst. + +### Geändert +- Erinnerungen werden als VALARM synchronisiert und klingeln auch in Apple Erinnerungen, Thunderbird und tasks.org. + +### Behoben +- Widgets funktionieren wieder nach dem Abhaken. +- Die Anmeldung per E-Mail klappt wieder bei mailbox.org und Posteo. diff --git a/fastlane/metadata/android/en-GB/changelogs/1020099.txt b/fastlane/metadata/android/en-GB/changelogs/1020099.txt new file mode 100644 index 0000000..1d69555 --- /dev/null +++ b/fastlane/metadata/android/en-GB/changelogs/1020099.txt @@ -0,0 +1,11 @@ +### Added +- Reminders can count from a task's start as well as its due date. +- All-day tasks can have reminders at set times, e.g. 09:00 and 17:00. +- Other apps can read and edit your local tasks through an OpenTasks provider, with your permission. + +### Changed +- Reminders sync as VALARMs, so they also ring in Apple Reminders, Thunderbird and tasks.org. + +### Fixed +- Widgets work again after ticking off tasks. +- Signing in by email works again for mailbox.org and Posteo. diff --git a/fastlane/metadata/android/en-US/changelogs/1020099.txt b/fastlane/metadata/android/en-US/changelogs/1020099.txt new file mode 100644 index 0000000..1d69555 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/1020099.txt @@ -0,0 +1,11 @@ +### Added +- Reminders can count from a task's start as well as its due date. +- All-day tasks can have reminders at set times, e.g. 09:00 and 17:00. +- Other apps can read and edit your local tasks through an OpenTasks provider, with your permission. + +### Changed +- Reminders sync as VALARMs, so they also ring in Apple Reminders, Thunderbird and tasks.org. + +### Fixed +- Widgets work again after ticking off tasks. +- Signing in by email works again for mailbox.org and Posteo. diff --git a/fastlane/metadata/android/fr-FR/changelogs/1020099.txt b/fastlane/metadata/android/fr-FR/changelogs/1020099.txt new file mode 100644 index 0000000..f79525f --- /dev/null +++ b/fastlane/metadata/android/fr-FR/changelogs/1020099.txt @@ -0,0 +1,11 @@ +### Nouveautés +- Un rappel peut partir du début ou de l'échéance d'une tâche. +- Rappels à heure fixe pour les tâches sur la journée. +- Avec votre accord, d'autres applis peuvent lire et modifier vos tâches locales via OpenTasks. + +### Modifications +- Les rappels se synchronisent en VALARM et sonnent aussi dans Rappels d'Apple, Thunderbird et tasks.org. + +### Corrections +- Les widgets marchent de nouveau après une tâche cochée. +- La connexion par e-mail marche de nouveau avec mailbox.org et Posteo. diff --git a/fastlane/metadata/android/fr-FR/full_description.txt b/fastlane/metadata/android/fr-FR/full_description.txt new file mode 100644 index 0000000..62588ab --- /dev/null +++ b/fastlane/metadata/android/fr-FR/full_description.txt @@ -0,0 +1,11 @@ +Agendula est une appli de tâches moderne et open source pour Android. + +Synchronisez vos tâches via CalDAV : ajoutez un compte Nextcloud, Radicale, Baïkal ou tout autre compte CalDAV et choisissez les listes de tâches à synchroniser. Ou gardez tout sur le téléphone : Agendula stocke vos tâches lui-même, sans compte et sans rien d'autre à installer. + +Vous utilisez déjà OpenTasks ou tasks.org ? Reliez Agendula à cette appli pour que les tâches synchronisées par DAVx5 ou similaire s'affichent telles quelles, et copiez-les quand vous voulez dans le stockage propre d'Agendula, sous Réglages → Stockage. + +Ce qu'il fait : listes gérées dans l'appli, dates d'échéance et de début, tâches sur toute la journée, sous-tâches, priorités, avancement, tâches récurrentes dont vous pouvez modifier une seule occurrence, plusieurs rappels par tâche avec report, import et export iCalendar, un widget d'écran d'accueil, des raccourcis de lanceur et une tuile de Réglages rapides. + +Le design est du vrai Material 3 Expressive de bout en bout, avec couleurs dynamiques, animations et formes expressives. + +Confidentialité : pas de télémétrie, pas d'analytique, pas de publicité. Agendula ne se connecte que pour synchroniser les comptes CalDAV que vous ajoutez. Sans compte, vos tâches ne quittent l'appareil que si vous les exportez. diff --git a/fastlane/metadata/android/fr-FR/short_description.txt b/fastlane/metadata/android/fr-FR/short_description.txt new file mode 100644 index 0000000..e248b26 --- /dev/null +++ b/fastlane/metadata/android/fr-FR/short_description.txt @@ -0,0 +1 @@ +Une appli de tâches Material 3 Expressive avec synchro CalDAV. diff --git a/fastlane/metadata/android/fr-FR/title.txt b/fastlane/metadata/android/fr-FR/title.txt new file mode 100644 index 0000000..5c368ac --- /dev/null +++ b/fastlane/metadata/android/fr-FR/title.txt @@ -0,0 +1 @@ +Agendula: Tasks diff --git a/fastlane/metadata/android/pt-BR/changelogs/1020099.txt b/fastlane/metadata/android/pt-BR/changelogs/1020099.txt new file mode 100644 index 0000000..ed99971 --- /dev/null +++ b/fastlane/metadata/android/pt-BR/changelogs/1020099.txt @@ -0,0 +1,11 @@ +### Novidades +- Lembretes podem contar a partir do início da tarefa ou do prazo. +- Tarefas de dia inteiro podem ter lembretes em horários fixos. +- Outros apps podem ler e editar suas tarefas locais via OpenTasks, com sua permissão. + +### Alterações +- Lembretes sincronizam como VALARM e tocam também no Lembretes da Apple, Thunderbird e tasks.org. + +### Correções +- Widgets voltam a funcionar após marcar tarefas. +- O login por e-mail volta a funcionar no mailbox.org e Posteo. diff --git a/floret-kit b/floret-kit index 0b44d35..e8d3332 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 0b44d353d2a617a0c0e0b9f8c71948e2e97e37e4 +Subproject commit e8d33329c81c76dc72887efd2ee1a3d92045844d diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 57d8d63..92afa2b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -51,10 +51,9 @@ okhttp = "4.12.0" xpp3 = "1.1.6" # --- :caldav (our CalDAV protocol layer) ------------------------------------- -# SRV/TXT lookups for RFC 6764 discovery. Android's resolver exposes no SRV API -# below API 29's DnsResolver (which is callback-only and cannot do TXT paths), -# and JNDI's DNS provider does not exist on Android at all. dnsjava is what DAVx5 -# uses. BSD-3 — it goes on the attribution screen with the rest. +# Parses the raw SRV/TXT answers for RFC 6764 discovery; the lookup itself goes +# through Android's DnsResolver, so it follows the network and Private DNS. +# BSD-3 — it goes on the attribution screen with the rest. dnsjava = "3.6.3" # WebDAV-Push delivery. The connector only: DAVx5 also ships the embedded FCM # distributor, which pulls in Play Services and would get us flagged on F-Droid. diff --git a/scripts/check_r8_keeps.py b/scripts/check_r8_keeps.py new file mode 100755 index 0000000..7463f3e --- /dev/null +++ b/scripts/check_r8_keeps.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Fail when R8 keeps one of our classes by name but strips its no-arg constructor. + +A keep rule that names a class but no members keeps the class and lets R8 drop +the constructor nothing calls directly. Anything that then instantiates it by +reflection (Room, Glance's ActionCallback, WorkManager) fails at runtime only, +in release builds only (#1, #75). R8 reports both halves: seeds.txt lists what +keep rules pinned, usage.txt what it removed. + + scripts/check_r8_keeps.py [mapping dir] default: fullReleaseTest's +""" +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +DEFAULT_DIR = ROOT / "app/build/outputs/mapping/fullReleaseTest" +PACKAGES = ("de.jeanlucmakiola.",) + + +def kept_classes(seeds: Path) -> set[str]: + return { + line.strip() + for line in seeds.read_text().splitlines() + if line.startswith(PACKAGES) and ":" not in line + } + + +def classes_without_default_ctor(usage: Path) -> set[str]: + stripped, current = set(), None + for line in usage.read_text().splitlines(): + if not line.startswith(" "): + current = line.rstrip(":") if line.endswith(":") else None + elif current and line.strip().endswith(" ()"): + stripped.add(current) + return stripped + + +def main() -> int: + mapping = Path(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_DIR + seeds, usage = mapping / "seeds.txt", mapping / "usage.txt" + if not seeds.exists() or not usage.exists(): + print(f"ERROR: no seeds.txt/usage.txt in {mapping}; build a minified variant first.", file=sys.stderr) + return 1 + broken = sorted(kept_classes(seeds) & classes_without_default_ctor(usage)) + for name in broken: + print(f"ERROR: {name} is kept by a rule but R8 removed its no-arg constructor.", file=sys.stderr) + if broken: + print("Keep the constructor too: -keep class ... { (); }", file=sys.stderr) + return 1 + print(f"OK: every kept class under {', '.join(PACKAGES)} still has its no-arg constructor.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())