diff --git a/.gitignore b/.gitignore index db92862..632e80e 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,7 @@ Thumbs.db # KSP .ksp/ + +# Local agent notes: machine-specific build setup and on-device rules, not +# anything the project itself depends on. +/CLAUDE.md diff --git a/README.md b/README.md index a67a934..86bea13 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@

Agendula

A modern Material 3 Expressive task app for Android.
-Reads, writes, and reminds — on top of an existing tasks provider, with no own -sync stack.

+Keeps your tasks on your device, or on top of a tasks provider you already use. +Open standards, no account required.

CI Android 10+ @@ -15,31 +15,53 @@ sync stack.

Agendula is the task-list sibling to [Calendula](https://codeberg.org/jlmakiola/calendula). -Where Calendula is a pure front-end over Android's `CalendarContract`, Agendula is -a pure front-end over the **OpenTasks `TaskContract` provider** — the store that -DAVx5 (and SmoothSync, DecSync, …) syncs your CalDAV `VTODO` tasks into. No own -database, no reinvented sync. +Where Calendula is a pure front-end over Android's `CalendarContract`, Agendula +keeps its own store, designed around RFC 5545's `VTODO` — the same tasks DAVx5 +(and SmoothSync, DecSync, …) sync out of your CalDAV server. It can also read and +write a tasks provider you already have, for anyone already syncing that way. The name rhymes with its sibling on purpose: **Agendula** is *agenda* — Latin for “things to be done” — given Calendula's `-ula` ending. Calendula keeps your days; Agendula keeps your to-dos. (A Calendula flower head is botanically a cluster of many small *florets* — so the two apps are florets of one bloom.) -> **Status: data layer done, UI in progress.** The full non-visual stack over -> the `TaskContract` provider — provider resolution, live-updating reads, -> writes, smart-list filtering, and a self-scheduled reminder engine — is built -> and unit-tested. The Material 3 Expressive screens are now being built on top, -> one at a time. See [`docs/ROADMAP.md`](docs/ROADMAP.md) for status, +## Where your tasks live — your choice + +| | Where | Sync | Needs | +|---|---|---|---| +| **On your device** *(default)* | Agendula's own database | none yet — CalDAV sync of our own is planned | nothing. No account, no permissions, no other app | +| **In a provider you already use** | OpenTasks or tasks.org | whatever syncs it for you — DAVx5 and friends | that app installed, and its read/write permission | + +Agendula's own store is an ordinary app database — nothing is published to other +apps, so there is no authority to clash over and no permission to grant. It +**coexists with OpenTasks rather than replacing it**: installing one never breaks +the other, and if you already sync through a provider, that keeps working exactly +as it did. + +Recurring tasks are expanded per RFC 5545, and everything the schema does not +model is round-tripped verbatim rather than dropped — so passing your tasks +through Agendula does not quietly lose fields a server sent. + +Your tasks are exportable as standard iCalendar `.ics` files at any time, because +data you can't take with you isn't really yours. + +> **Status: backend complete, UI catching up.** Storage, reads and +> writes, smart-list filtering, a self-scheduled reminder engine, and export are +> built and unit-tested. The Material 3 Expressive screens are being built on +> top, one at a time — the storage-mode picker and export screen are not there +> yet. See [`docs/ROADMAP.md`](docs/ROADMAP.md) for status, > [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for how it's built, and -> [`docs/PLAN.md`](docs/PLAN.md) for the A-now-B-later design rationale. +> [`docs/STORAGE-AND-SYNC.md`](docs/STORAGE-AND-SYNC.md) for why storage works +> the way it does. ## Sync sources (by design) -Agendula works with anything that writes to the tasks provider — **DAVx5** -(CalDAV), **SmoothSync**, **CalDAV-Sync**, **DecSync CC**, or any Android sync -adapter — because it builds on the provider, not on any one sync app. Google -Tasks / Microsoft To Do are out of scope by design (proprietary; they would mean -owning a sync stack). Open standards — CalDAV / iCalendar / DecSync — are the lane. +In external-provider mode Agendula works with anything that writes to that provider — +**DAVx5** (CalDAV), **SmoothSync**, **CalDAV-Sync**, **DecSync CC**, or any +Android sync adapter — because it builds on the provider, not on any one sync +app. Google Tasks / Microsoft To Do are out of scope by design (proprietary; they +would mean owning a sync stack). Open standards — CalDAV / iCalendar / DecSync — +are the lane. ## Translations diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fb34c3e..1639fed 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -129,6 +129,10 @@ android { isReturnDefaultValues = true } } + + // MigrationTestHelper reads the exported schemas out of the test APK's + // assets, so app/schemas/ has to ship with the instrumented tests. + sourceSets.getByName("androidTest").assets.srcDir("$projectDir/schemas") } kotlin { @@ -137,11 +141,27 @@ kotlin { } } +// Export each Room schema version to app/schemas/ and commit it. That JSON is +// what MigrationTestHelper reads to build an old database and migrate it, so +// without it a migration can only be tested by hand. +ksp { + arg("room.schemaLocation", "$projectDir/schemas") +} + dependencies { + // Not a dependency we use directly — lifecycle already drags it in at 1.7.3. + // AGP's consistent resolution then pins androidTest to the app classpath, and + // room-testing's MigrationTestHelper needs 1.8+ to deserialize the exported + // schema; on 1.7.3 it dies with an AbstractMethodError. Raise it in one place. + constraints { + implementation(libs.kotlinx.serialization.json) + } + implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.process) implementation(libs.androidx.activity.compose) implementation(platform(libs.androidx.compose.bom)) @@ -157,7 +177,17 @@ dependencies { implementation(libs.androidx.navigation.compose) ksp(libs.hilt.compiler) + // RFC 5545 recurrence expansion, in-process. Pinned at 0.12.2 — 0.16.0 + // removed RecurrenceSet. rfc5545-datetime comes with it and is part of its + // API surface, so it isn't declared separately. + implementation(libs.dmfs.lib.recur) + + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + ksp(libs.androidx.room.compiler) + implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.documentfile) implementation(libs.androidx.glance.appwidget) implementation(libs.androidx.glance.material3) @@ -185,6 +215,7 @@ dependencies { androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.test.rules) androidTestImplementation(libs.truth) + androidTestImplementation(libs.androidx.room.testing) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.ui.test.junit4) } diff --git a/app/schemas/de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase/1.json b/app/schemas/de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase/1.json new file mode 100644 index 0000000..485362a --- /dev/null +++ b/app/schemas/de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase/1.json @@ -0,0 +1,522 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "c94852274d874fe255ee76e1e46a3003", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `display_name` TEXT NOT NULL, `principal_url` TEXT, `home_set_url` TEXT, `username` TEXT, `last_sync_at` INTEGER, `last_sync_error` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "principalUrl", + "columnName": "principal_url", + "affinity": "TEXT" + }, + { + "fieldPath": "homeSetUrl", + "columnName": "home_set_url", + "affinity": "TEXT" + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "lastSyncAt", + "columnName": "last_sync_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastSyncError", + "columnName": "last_sync_error", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "task_lists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `account_id` INTEGER, `is_visible` INTEGER NOT NULL DEFAULT 1, `is_synced` INTEGER NOT NULL DEFAULT 1, `owner` TEXT, `is_read_only` INTEGER NOT NULL DEFAULT 0, `sort_order` INTEGER NOT NULL DEFAULT 0, `href` TEXT, `ctag` TEXT, `sync_token` TEXT, `is_dirty` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`account_id`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "account_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "isVisible", + "columnName": "is_visible", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "isSynced", + "columnName": "is_synced", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "owner", + "columnName": "owner", + "affinity": "TEXT" + }, + { + "fieldPath": "isReadOnly", + "columnName": "is_read_only", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "sortOrder", + "columnName": "sort_order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "href", + "columnName": "href", + "affinity": "TEXT" + }, + { + "fieldPath": "ctag", + "columnName": "ctag", + "affinity": "TEXT" + }, + { + "fieldPath": "syncToken", + "columnName": "sync_token", + "affinity": "TEXT" + }, + { + "fieldPath": "isDirty", + "columnName": "is_dirty", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_task_lists_account_id", + "unique": false, + "columnNames": [ + "account_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_task_lists_account_id` ON `${TABLE_NAME}` (`account_id`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "account_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "tasks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `list_id` INTEGER NOT NULL, `uid` TEXT NOT NULL, `href` TEXT, `etag` TEXT, `title` TEXT, `description` TEXT, `location` TEXT, `url` TEXT, `color` INTEGER, `status` INTEGER NOT NULL DEFAULT 0, `percent_complete` INTEGER, `completed_at` INTEGER, `priority` INTEGER NOT NULL DEFAULT 0, `classification` INTEGER, `dtstart` INTEGER, `due` INTEGER, `duration` TEXT, `is_all_day` INTEGER NOT NULL DEFAULT 0, `timezone` TEXT, `rrule` TEXT, `rdate` TEXT, `exdate` TEXT, `recurrence_id` INTEGER, `master_id` INTEGER, `parent_id` INTEGER, `sort_order` INTEGER NOT NULL DEFAULT 0, `created_at` INTEGER, `last_modified` INTEGER, `sequence` INTEGER NOT NULL DEFAULT 0, `is_dirty` INTEGER NOT NULL DEFAULT 0, `is_deleted` INTEGER NOT NULL DEFAULT 0, `unknown_properties` TEXT, FOREIGN KEY(`list_id`) REFERENCES `task_lists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`master_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parent_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "listId", + "columnName": "list_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "href", + "columnName": "href", + "affinity": "TEXT" + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "location", + "columnName": "location", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT" + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "percentComplete", + "columnName": "percent_complete", + "affinity": "INTEGER" + }, + { + "fieldPath": "completedAt", + "columnName": "completed_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "classification", + "columnName": "classification", + "affinity": "INTEGER" + }, + { + "fieldPath": "dtstart", + "columnName": "dtstart", + "affinity": "INTEGER" + }, + { + "fieldPath": "due", + "columnName": "due", + "affinity": "INTEGER" + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "TEXT" + }, + { + "fieldPath": "isAllDay", + "columnName": "is_all_day", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "timezone", + "columnName": "timezone", + "affinity": "TEXT" + }, + { + "fieldPath": "rrule", + "columnName": "rrule", + "affinity": "TEXT" + }, + { + "fieldPath": "rdate", + "columnName": "rdate", + "affinity": "TEXT" + }, + { + "fieldPath": "exdate", + "columnName": "exdate", + "affinity": "TEXT" + }, + { + "fieldPath": "recurrenceId", + "columnName": "recurrence_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "masterId", + "columnName": "master_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "sortOrder", + "columnName": "sort_order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastModified", + "columnName": "last_modified", + "affinity": "INTEGER" + }, + { + "fieldPath": "sequence", + "columnName": "sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isDirty", + "columnName": "is_dirty", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isDeleted", + "columnName": "is_deleted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "unknownProperties", + "columnName": "unknown_properties", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_tasks_list_id_is_deleted", + "unique": false, + "columnNames": [ + "list_id", + "is_deleted" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_list_id_is_deleted` ON `${TABLE_NAME}` (`list_id`, `is_deleted`)" + }, + { + "name": "index_tasks_parent_id", + "unique": false, + "columnNames": [ + "parent_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_parent_id` ON `${TABLE_NAME}` (`parent_id`)" + }, + { + "name": "index_tasks_master_id_recurrence_id", + "unique": false, + "columnNames": [ + "master_id", + "recurrence_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_master_id_recurrence_id` ON `${TABLE_NAME}` (`master_id`, `recurrence_id`)" + }, + { + "name": "index_tasks_is_dirty", + "unique": false, + "columnNames": [ + "is_dirty" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_is_dirty` ON `${TABLE_NAME}` (`is_dirty`)" + }, + { + "name": "index_tasks_list_id_uid_recurrence_id", + "unique": true, + "columnNames": [ + "list_id", + "uid", + "recurrence_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tasks_list_id_uid_recurrence_id` ON `${TABLE_NAME}` (`list_id`, `uid`, `recurrence_id`)" + } + ], + "foreignKeys": [ + { + "table": "task_lists", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "list_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "tasks", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "master_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "tasks", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "parent_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "task_alarms", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `task_id` INTEGER NOT NULL, `minutes_before` INTEGER NOT NULL, `reference` TEXT NOT NULL DEFAULT 'DUE', `message` TEXT, FOREIGN KEY(`task_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "taskId", + "columnName": "task_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minutesBefore", + "columnName": "minutes_before", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reference", + "columnName": "reference", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'DUE'" + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_task_alarms_task_id", + "unique": false, + "columnNames": [ + "task_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_task_alarms_task_id` ON `${TABLE_NAME}` (`task_id`)" + } + ], + "foreignKeys": [ + { + "table": "tasks", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "task_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c94852274d874fe255ee76e1e46a3003')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/assets/tasks-v23.db b/app/src/androidTest/assets/tasks-v23.db new file mode 100644 index 0000000..e305e90 Binary files /dev/null and b/app/src/androidTest/assets/tasks-v23.db differ diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImportTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImportTest.kt new file mode 100644 index 0000000..c040421 --- /dev/null +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImportTest.kt @@ -0,0 +1,290 @@ +package de.jeanlucmakiola.agendula.data.tasks.legacy + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.room.Room +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 de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference +import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity +import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase +import de.jeanlucmakiola.agendula.domain.TaskStatus +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import java.io.File +import java.util.UUID +import kotlin.time.Instant + +/** + * The one-shot import, against `assets/tasks-v23.db` — the dmfs v23 fixture + * `scripts/make_import_fixture.py` seeds. Instrumented because both halves need + * a real SQLite: the source file and Room. + */ +@RunWith(AndroidJUnit4::class) +class OneShotImportTest { + + @get:Rule + val temp = TemporaryFolder() + + private val context: Context = ApplicationProvider.getApplicationContext() + private lateinit var scope: CoroutineScope + private lateinit var prefs: DataStore + private lateinit var db: TasksDatabase + private lateinit var importer: OneShotImport + + @Before + fun setUp() { + scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + prefs = PreferenceDataStoreFactory.create(scope = scope) { + temp.newFile("import-${counter++}.preferences_pb").also(File::delete) + } + db = Room.inMemoryDatabaseBuilder(context, TasksDatabase::class.java) + .allowMainThreadQueries() + .build() + importer = OneShotImport(context, db, prefs) + legacyFile().delete() + archiveFile().delete() + } + + @After + fun tearDown() { + db.close() + scope.cancel() + legacyFile().delete() + archiveFile().delete() + } + + private fun legacyFile() = context.getDatabasePath(OneShotImport.LEGACY_NAME) + private fun archiveFile() = context.getDatabasePath(OneShotImport.ARCHIVE_NAME) + + /** The fixture, copied out of the test APK's assets. */ + private fun fixture(target: File = temp.newFile("tasks-v23-copy.db")): File { + InstrumentationRegistry.getInstrumentation().context.assets.open(FIXTURE).use { source -> + target.outputStream().use(source::copyTo) + } + return target + } + + private fun taskRows(): Map = + db.tasks().tasks(null, includeCompleted = true).associate { it.task.title!! to it.task } + + // --- what lands ----------------------------------------------------------- + + @Test + fun importsEveryLiveTaskAndLeavesTheDeletedOneBehind() { + val counts = importer.importFrom(fixture()) + + assertThat(counts).isEqualTo(ImportCounts(lists = 3, tasks = 8, alarms = 2)) + assertThat(taskRows().keys).containsExactly( + "Buy milk", + "Call the dentist", + "Gather receipts", + "Renew domain", + "Water the plants", + "Team offsite", + "Task in a hidden list", + "Ship the release", + ) + } + + @Test + fun importsEveryListAsADeviceOnlyListWithItsFlags() { + importer.importFrom(fixture()) + + val lists = db.taskLists().lists().associateBy { it.list.name } + assertThat(lists.keys).containsExactly("Personal", "Hidden list", "Work") + assertThat(lists.values.map { it.list.accountId }).containsExactly(null, null, null) + assertThat(lists.getValue("Personal").list.isVisible).isTrue() + assertThat(lists.getValue("Hidden list").list.isVisible).isFalse() + // The list that sat under a real account: still imported, owner kept. + assertThat(lists.getValue("Work").list.owner).isEqualTo("Me") + assertThat(lists.getValue("Work").list.color).isEqualTo(0xFF2244AA.toInt()) + } + + @Test + fun carriesTheTaskFieldsAcross() { + importer.importFrom(fixture()) + val tasks = taskRows() + + val milk = tasks.getValue("Buy milk") + assertThat(milk.due).isEqualTo(Instant.fromEpochMilliseconds(T0 + DAY)) + assertThat(milk.status).isEqualTo(TaskStatus.NEEDS_ACTION) + assertThat(milk.createdAt).isEqualTo(Instant.fromEpochMilliseconds(T0)) + + val dentist = tasks.getValue("Call the dentist") + assertThat(dentist.status).isEqualTo(TaskStatus.IN_PROCESS) + assertThat(dentist.percentComplete).isEqualTo(40) + + val domain = tasks.getValue("Renew domain") + assertThat(domain.status).isEqualTo(TaskStatus.COMPLETED) + assertThat(domain.completedAt).isEqualTo(Instant.fromEpochMilliseconds(T0 - DAY)) + + val plants = tasks.getValue("Water the plants") + assertThat(plants.rrule).isEqualTo("FREQ=WEEKLY;BYDAY=MO,TH") + assertThat(plants.timezone).isEqualTo("Europe/Berlin") + assertThat(plants.dtstart).isEqualTo(Instant.fromEpochMilliseconds(T0)) + + assertThat(tasks.getValue("Team offsite").isAllDay).isTrue() + } + + // --- uids ----------------------------------------------------------------- + + @Test + fun keepsExistingUidsAndMintsOneWhereTheLegacyRowHadNone() { + importer.importFrom(fixture()) + val tasks = taskRows() + + assertThat(tasks.getValue("Buy milk").uid).isEqualTo("a1b2c3d4-0000-4000-8000-000000000001") + // The external-account row's uid is what lets it be re-attached later. + assertThat(tasks.getValue("Ship the release").uid) + .isEqualTo("a1b2c3d4-0000-4000-8000-000000000009") + + val minted = tasks.getValue("Call the dentist").uid + assertThat(minted).isNotEmpty() + assertThat(UUID.fromString(minted).version()).isEqualTo(4) + assertThat(tasks.values.map { it.uid }.toSet()).hasSize(tasks.size) + } + + // --- the id remap --------------------------------------------------------- + + @Test + fun remapsListIdsOntoTheNewRowIds() { + importer.importFrom(fixture()) + + val lists = db.taskLists().lists().associateBy { it.list.name } + val byList = db.tasks().tasks(null, includeCompleted = true) + .groupBy { it.task.listId } + .mapValues { (_, rows) -> rows.size } + + assertThat(byList[lists.getValue("Personal").list.id]).isEqualTo(6) + assertThat(byList[lists.getValue("Hidden list").list.id]).isEqualTo(1) + assertThat(byList[lists.getValue("Work").list.id]).isEqualTo(1) + // No task kept a dmfs row id that Room never handed out. + assertThat(byList.keys).containsExactlyElementsIn(lists.values.map { it.list.id }) + } + + @Test + fun remapsParentIdsOntoTheNewRowIds() { + importer.importFrom(fixture()) + val tasks = taskRows() + + val parent = tasks.getValue("Buy milk") + val child = tasks.getValue("Gather receipts") + assertThat(child.parentId).isEqualTo(parent.id) + assertThat(db.tasks().subtasks(parent.id).map { it.task.title }).containsExactly("Gather receipts") + assertThat(tasks.values.filter { it.parentId != null }).hasSize(1) + } + + // --- alarms --------------------------------------------------------------- + + @Test + fun importsAlarmsAndSkipsEveryOtherProperty() { + importer.importFrom(fixture()) + val tasks = taskRows() + + assertThat(db.alarms().all()).hasSize(2) + + val milk = db.alarms().forTask(tasks.getValue("Buy milk").id).single() + assertThat(milk.minutesBefore).isEqualTo(30) + assertThat(milk.reference).isEqualTo(AlarmReference.DUE) + assertThat(milk.message).isNull() + + val release = db.alarms().forTask(tasks.getValue("Ship the release").id).single() + assertThat(release.minutesBefore).isEqualTo(1440) + assertThat(release.reference).isEqualTo(AlarmReference.DUE) + assertThat(release.message).isEqualTo("Ship it") + + // The category property on task 1 is not an alarm. + assertThat(db.alarms().all().map { it.message }).doesNotContain("Errands") + } + + // --- running it ----------------------------------------------------------- + + @Test + fun runIfNeededImportsArchivesTheSourceAndThenDoesNothing() = runBlocking { + fixture(legacyFile()) + + val first = importer.runIfNeeded() + + assertThat(first).isEqualTo(ImportResult.Imported(ImportCounts(3, 8, 2))) + assertThat(legacyFile().exists()).isFalse() + assertThat(archiveFile().exists()).isTrue() + assertThat(importer.isDone.first()).isTrue() + + val second = importer.runIfNeeded() + + assertThat(second).isEqualTo(ImportResult.AlreadyDone) + assertThat(taskRows()).hasSize(8) + } + + @Test + fun anInterruptedImportResumesFromTheArchiveWithoutDoubling() = runBlocking { + // The process dying between the commit and the flag write is the one gap + // the DataStore flag cannot cover on its own. Because the rename happens + // first and the import always replaces, the next run finds the archive and + // redoes the same work rather than importing a second copy. + fixture(legacyFile()) + importer.runIfNeeded() + importer.clearCompletion() + + val resumed = importer.runIfNeeded() + + assertThat(resumed).isEqualTo(ImportResult.Imported(ImportCounts(3, 8, 2))) + assertThat(taskRows()).hasSize(8) + assertThat(db.taskLists().lists()).hasSize(3) + assertThat(db.alarms().all()).hasSize(2) + } + + @Test + fun runIfNeededMarksItselfDoneWhenThereIsNoLegacyDatabase() = runBlocking { + assertThat(importer.runIfNeeded()).isEqualTo(ImportResult.NothingToImport) + assertThat(importer.isDone.first()).isTrue() + assertThat(taskRows()).isEmpty() + } + + @Test + fun reimportFromTheArchiveReplacesRatherThanMerges() = runBlocking { + fixture(legacyFile()) + importer.runIfNeeded() + + val again = importer.reimportFromArchive() + + assertThat(again).isEqualTo(ImportResult.Imported(ImportCounts(3, 8, 2))) + assertThat(db.taskLists().lists()).hasSize(3) + assertThat(taskRows()).hasSize(8) + assertThat(db.alarms().all()).hasSize(2) + assertThat(archiveFile().exists()).isTrue() + } + + @Test + fun replacingTwiceFromTheSameFileLeavesOneCopy() { + importer.importFrom(fixture()) + val counts = importer.importFrom(fixture(temp.newFile("second.db")), replaceExisting = true) + + assertThat(counts).isEqualTo(ImportCounts(3, 8, 2)) + assertThat(taskRows()).hasSize(8) + assertThat(db.taskLists().lists()).hasSize(3) + assertThat(db.alarms().all()).hasSize(2) + } + + private companion object { + const val FIXTURE = "tasks-v23.db" + const val T0 = 1_768_467_600_000L + const val DAY = 86_400_000L + var counter = 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 new file mode 100644 index 0000000..69f4e33 --- /dev/null +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSourceTest.kt @@ -0,0 +1,389 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +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.TaskQuery +import de.jeanlucmakiola.agendula.domain.TaskForm +import de.jeanlucmakiola.agendula.domain.TaskStatus +import org.junit.After +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.Instant + +/** + * The seam over Room, exercised through [de.jeanlucmakiola.agendula.data.tasks + * .TasksDataSource] rather than the DAOs — recurrence expansion and override + * forking only exist at this level. + */ +@RunWith(AndroidJUnit4::class) +class RoomTasksDataSourceTest { + + private lateinit var db: TasksDatabase + private lateinit var source: RoomTasksDataSource + private var listId = 0L + + /** Truncated to the store's granularity: instants are columns of epoch millis. */ + private val now get() = Instant.fromEpochMilliseconds(Clock.System.now().toEpochMilliseconds()) + + @Before + fun setUp() { + db = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + TasksDatabase::class.java, + ).allowMainThreadQueries().build() + source = RoomTasksDataSource(db) + listId = source.createLocalList("Personal", 0xFF112233.toInt()) + } + + @After + fun tearDown() = db.close() + + private fun form( + title: String = "task", + due: Instant? = null, + percentComplete: Int? = null, + ) = TaskForm(title = title, listId = listId, due = due, percentComplete = percentComplete) + + /** Turns [taskId] into a weekly series anchored at [anchor]. */ + private fun makeRecurring(taskId: Long, anchor: Instant, rule: String = "FREQ=WEEKLY") { + val entity = db.tasks().entity(taskId)!! + db.tasks().update(entity.copy(dtstart = anchor, due = anchor + 1.days, rrule = rule)) + } + + @Test + fun createsAndReadsBackALocalList() { + val lists = source.taskLists() + + assertThat(lists).hasSize(1) + assertThat(lists.single().name).isEqualTo("Personal") + // No account, so the list still has to report something the lists screen + // can group under. + assertThat(lists.single().isLocal).isTrue() + assertThat(lists.single().accountName).isEqualTo("Local") + } + + @Test + fun renamesAndRecoloursAList() { + source.updateList(listId, " Errands ", 0xFF445566.toInt()) + + val list = source.taskLists().single() + assertThat(list.name).isEqualTo("Errands") + assertThat(list.color).isEqualTo(0xFF445566.toInt()) + // Nothing to sync a device-only list to, so the edit leaves it clean. + assertThat(db.taskLists().entity(listId)!!.isDirty).isFalse() + } + + @Test + fun deletingAListTakesItsTasksWithIt() { + source.insertTask(form(title = "Buy milk")) + source.insertTask(form(title = "Call the bank")) + val other = source.createLocalList("Work", 0xFF778899.toInt()) + val keeper = source.insertTask(TaskForm(title = "Ship it", listId = other)) + + source.deleteList(listId) + + assertThat(source.taskLists().map { it.id }).containsExactly(other) + assertThat(source.tasks(TaskQuery(includeCompleted = true)).map { it.taskId }) + .containsExactly(keeper) + } + + @Test + fun createsAndReadsBackANonRecurringTask() { + val due = now + 1.days + val id = source.insertTask(form(title = "Buy milk", due = due)) + + val task = source.task(id)!! + + assertThat(task.taskId).isEqualTo(id) + assertThat(task.title).isEqualTo("Buy milk") + assertThat(task.due).isEqualTo(due) + assertThat(task.isRecurring).isFalse() + // A task that does not recur has no occurrence anchor, so it keys and edits + // by task id exactly as it did against the provider. + assertThat(task.occurrenceStart).isNull() + assertThat(task.occurrenceKey).isEqualTo("$id") + } + + @Test + fun mintsAUidForEveryTask() { + val id = source.insertTask(form()) + + assertThat(db.tasks().entity(id)!!.uid).isNotEmpty() + } + + @Test + fun expandsARecurringSeriesIntoManyOccurrences() { + val anchor = now + val id = source.insertTask(form(title = "Water the plants")) + makeRecurring(id, anchor) + + val occurrences = source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id } + + // The provider materialised exactly one upcoming occurrence; we expand the + // whole window, so a weekly series yields well over a hundred. + assertThat(occurrences.size).isGreaterThan(100) + assertThat(occurrences.map { it.occurrenceStart }).containsNoDuplicates() + assertThat(occurrences.map { it.occurrenceKey }).containsNoDuplicates() + assertThat(occurrences.all { it.isRecurring }).isTrue() + // Each occurrence keeps the series' length rather than the master's dates. + val first = occurrences.minBy { it.occurrenceStart!! } + assertThat(first.due!! - first.start!!).isEqualTo(1.days) + } + + @Test + fun exactlyOneOccurrenceIsTheCurrentOne() { + val id = source.insertTask(form()) + makeRecurring(id, now - 30.days) + + val occurrences = source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id } + + assertThat(occurrences.count { it.distanceFromCurrent == 0 }).isEqualTo(1) + assertThat(source.task(id)!!.distanceFromCurrent).isEqualTo(0) + } + + @Test + fun editingOneOccurrenceForksARecurrenceIdOverride() { + val anchor = now + val id = source.insertTask(form(title = "Water the plants")) + makeRecurring(id, anchor) + val target = source.tasks(TaskQuery(listId = listId)) + .filter { it.taskId == id } + .first { it.distanceFromCurrent == 1 } + + source.updateInstance(id, target.occurrenceStart!!, form(title = "Water them twice")) + + val override = db.tasks().override(id, target.occurrenceStart)!! + // RFC 5545's model: the override shares its master's UID — that is what + // makes it an override rather than a separate task. The dmfs provider + // detached the occurrence into a new task with its own UID instead. + assertThat(override.uid).isEqualTo(db.tasks().entity(id)!!.uid) + assertThat(override.masterId).isEqualTo(id) + assertThat(override.recurrenceId).isEqualTo(target.occurrenceStart) + assertThat(override.rrule).isNull() + assertThat(override.title).isEqualTo("Water them twice") + } + + @Test + fun completingOneOccurrenceLeavesTheRestOfTheSeriesOpen() { + val id = source.insertTask(form(title = "Water the plants")) + makeRecurring(id, now) + val open = { source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id } } + val before = open() + val target = before.first { it.distanceFromCurrent == 0 } + + source.setCompletedInstance(id, target.occurrenceStart!!, completed = true) + + // Writing the status onto the master would close the series: the master is + // the row the task query filters on, so every occurrence would vanish. + val after = open() + assertThat(after).hasSize(before.size - 1) + assertThat(after.map { it.occurrenceStart }).doesNotContain(target.occurrenceStart) + assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION) + + val override = db.tasks().override(id, target.occurrenceStart)!! + assertThat(override.uid).isEqualTo(db.tasks().entity(id)!!.uid) + assertThat(override.status).isEqualTo(TaskStatus.COMPLETED) + assertThat(override.rrule).isNull() + // The override stands for *that* occurrence, so it carries the + // occurrence's resolved times, not the master's anchor. + assertThat(override.dtstart).isEqualTo(target.occurrenceStart) + } + + @Test + fun reopeningACompletedOccurrenceReusesItsOverride() { + val id = source.insertTask(form(title = "Water the plants")) + makeRecurring(id, now) + val target = source.tasks(TaskQuery(listId = listId)) + .first { it.taskId == id && it.distanceFromCurrent == 0 } + + source.setCompletedInstance(id, target.occurrenceStart!!, completed = true) + source.setCompletedInstance(id, target.occurrenceStart, completed = false) + + assertThat(db.tasks().overrides(id)).hasSize(1) + assertThat(db.tasks().override(id, target.occurrenceStart)!!.status) + .isEqualTo(TaskStatus.NEEDS_ACTION) + assertThat(source.tasks(TaskQuery(listId = listId)).map { it.occurrenceStart }) + .contains(target.occurrenceStart) + } + + @Test + fun completingANonRecurringTaskThroughTheInstancePathWritesTheRowItself() { + val id = source.insertTask(form(title = "Buy milk", due = now + 1.days)) + + source.setCompletedInstance(id, now, completed = true) + + assertThat(db.tasks().overrides(id)).isEmpty() + assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.COMPLETED) + } + + @Test + fun anOverrideReplacesOnlyItsOwnOccurrence() { + val id = source.insertTask(form(title = "Water the plants")) + makeRecurring(id, now) + // The list holds this series alone, so no filter is needed — and none can + // be written on taskId, since the override reports its own row id. + val before = source.tasks(TaskQuery(listId = listId)) + val target = before.first { it.distanceFromCurrent == 1 } + + source.updateInstance(id, target.occurrenceStart!!, form(title = "Water them twice")) + + val after = source.tasks(TaskQuery(listId = listId)) + assertThat(after).hasSize(before.size) + val edited = after.single { it.title == "Water them twice" } + assertThat(edited.occurrenceStart).isEqualTo(target.occurrenceStart) + assertThat(after.filter { it.occurrenceStart == target.occurrenceStart }).hasSize(1) + } + + /** + * An edited occurrence addresses its own row, not the master's. That is what + * sends the *next* edit down `updateTask` rather than forking a second time: + * an override carries no rule, so it reads back as non-recurring. + */ + @Test + fun anEditedOccurrenceReportsTheOverridesOwnId() { + val id = source.insertTask(form(title = "Water the plants")) + makeRecurring(id, now) + val target = source.tasks(TaskQuery(listId = listId)).first { it.distanceFromCurrent == 1 } + + source.updateInstance(id, target.occurrenceStart!!, form(title = "Water them twice")) + + val edited = source.tasks(TaskQuery(listId = listId)).single { it.title == "Water them twice" } + val overrideId = db.tasks().override(id, target.occurrenceStart)!!.id + assertThat(edited.taskId).isEqualTo(overrideId) + assertThat(edited.taskId).isNotEqualTo(id) + assertThat(source.task(overrideId)!!.isRecurring).isFalse() + } + + @Test + fun editingASeriesDoesNotReAnchorItWhenOneOccurrenceIsEdited() { + val anchor = now + val id = source.insertTask(form()) + makeRecurring(id, anchor) + val target = source.tasks(TaskQuery(listId = listId)) + .filter { it.taskId == id } + .first { it.distanceFromCurrent == 2 } + + source.updateInstance(id, target.occurrenceStart!!, form(due = now + 99.days)) + + assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(anchor) + } + + @Test + fun updatingANonRecurringTaskWritesThroughToItsRow() { + val id = source.insertTask(form(title = "old")) + + source.updateTask(id, form(title = "new")) + + assertThat(source.task(id)!!.title).isEqualTo("new") + } + + @Test + fun completionTogglesTheWholeTriple() { + val id = source.insertTask(form()) + + source.setCompleted(id, completed = true) + val done = db.tasks().entity(id)!! + assertThat(done.status).isEqualTo(TaskStatus.COMPLETED) + assertThat(done.percentComplete).isEqualTo(100) + assertThat(done.completedAt).isNotNull() + + source.setCompleted(id, completed = false) + assertThat(db.tasks().entity(id)!!.completedAt).isNull() + } + + @Test + fun completedTasksAreExcludedUnlessAskedFor() { + val id = source.insertTask(form()) + source.setCompleted(id, completed = true) + + assertThat(source.tasks(TaskQuery(listId = listId, includeCompleted = false))).isEmpty() + assertThat(source.tasks(TaskQuery(listId = listId, includeCompleted = true))).hasSize(1) + } + + @Test + fun alarmsRoundTripAndReplaceRatherThanAccumulate() { + val id = source.insertTask(form(due = now + 1.days)) + + source.setAlarm(id, 30) + assertThat(source.alarms()[id]).isEqualTo(30) + + source.setAlarm(id, 60) + assertThat(db.alarms().forTask(id)).hasSize(1) + assertThat(source.alarms()[id]).isEqualTo(60) + + source.setAlarm(id, null) + assertThat(source.alarms()).doesNotContainKey(id) + } + + @Test + fun forkingAnOccurrenceCarriesTheReminderOntoIt() { + val id = source.insertTask(form(due = now + 1.days)) + makeRecurring(id, now) + source.setAlarm(id, 30) + val target = source.tasks(TaskQuery(listId = listId)) + .filter { it.taskId == id } + .first { it.distanceFromCurrent == 1 } + + source.updateInstance(id, target.occurrenceStart!!, form()) + + val override = db.tasks().override(id, target.occurrenceStart)!! + assertThat(db.alarms().forTask(override.id).single().minutesBefore).isEqualTo(30) + } + + @Test + fun deletingATaskInALocalListRemovesItOutright() { + val id = source.insertTask(form()) + + source.deleteTask(id) + + // No account knows about it, so there is nothing to tombstone for. + assertThat(db.tasks().entity(id)).isNull() + } + + @Test + fun deletingASeriesTakesItsOverridesWithIt() { + val id = source.insertTask(form()) + makeRecurring(id, now) + val target = source.tasks(TaskQuery(listId = listId)) + .filter { it.taskId == id } + .first { it.distanceFromCurrent == 1 } + source.updateInstance(id, target.occurrenceStart!!, form(title = "moved")) + + source.deleteTask(id) + + assertThat(db.tasks().allOverrides(listId)).isEmpty() + } + + @Test + fun subtasksReadBackUnderTheirParent() { + val parent = source.insertTask(form(title = "Prepare invoice")) + val child = source.insertTask(form(title = "Gather receipts").copy(parentId = parent)) + + assertThat(source.subtasks(parent).map { it.taskId }).containsExactly(child) + } + + @Test + fun exportReadsMastersNotOccurrences() { + val id = source.insertTask(form(title = "Water the plants")) + makeRecurring(id, now) + + val exported = source.exportTasks(listId) + + // One row carrying the rule, not one row per occurrence with the rule lost. + assertThat(exported).hasSize(1) + assertThat(exported.single().rrule).isEqualTo("FREQ=WEEKLY") + assertThat(exported.single().uid).isNotEmpty() + } + + @Test + fun insertingIntoAMissingListFails() { + val thrown = runCatching { source.insertTask(form().copy(listId = 9_999)) }.exceptionOrNull() + + assertThat(thrown).isNotNull() + } +} diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseMigrationTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseMigrationTest.kt new file mode 100644 index 0000000..cf4b272 --- /dev/null +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseMigrationTest.kt @@ -0,0 +1,68 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import androidx.room.testing.MigrationTestHelper +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * The migration harness, proven against the committed schema in `app/schemas/`. + * + * There is one schema version today, so all there is to assert is that the helper + * can build v1 from the exported JSON, seed it, and validate it back — i.e. the + * export, the assets wiring and the identity hash all line up. That is the point: + * the first real migration only has to add its own case. + * + * **Adding a v1 → v2 case.** When sync adds columns, bump [TasksDatabase]'s + * `version`, let KSP export `2.json`, declare the `Migration(1, 2)` next to the + * database, and add a test here shaped like this: + * + * ``` + * helper.createDatabase(TEST_DB, 1).use { db -> + * db.execSQL("INSERT INTO task_lists (name, color) VALUES ('Groceries', 0)") + * } + * helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2).use { db -> + * // read the seeded rows back — validation proves the shape, not the data + * } + * ``` + */ +@RunWith(AndroidJUnit4::class) +class TasksDatabaseMigrationTest { + + @get:Rule + val helper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + TasksDatabase::class.java, + ) + + @Test + fun buildsV1FromTheExportedSchema() { + helper.createDatabase(TEST_DB, 1).use { db -> + db.execSQL("INSERT INTO task_lists (id, name, color) VALUES (1, 'Groceries', 0)") + db.execSQL("INSERT INTO tasks (id, list_id, uid, title) VALUES (1, 1, 'uid-1', 'Buy milk')") + + db.query("SELECT title FROM tasks").use { cursor -> + assertThat(cursor.moveToFirst()).isTrue() + assertThat(cursor.getString(0)).isEqualTo("Buy milk") + } + } + } + + @Test + fun validatesV1AgainstTheExportedSchema() { + helper.createDatabase(TEST_DB, 1).close() + + // No migrations to run: v1 is opened and checked against 1.json, which is + // what proves the harness rather than the schema. + helper.runMigrationsAndValidate(TEST_DB, 1, true).use { db -> + assertThat(db.version).isEqualTo(1) + } + } + + private companion object { + const val TEST_DB = "migration-test.db" + } +} diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabasePerformanceTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabasePerformanceTest.kt new file mode 100644 index 0000000..594e500 --- /dev/null +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabasePerformanceTest.kt @@ -0,0 +1,107 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +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.TaskQuery +import de.jeanlucmakiola.agendula.domain.TaskForm +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import kotlin.time.Clock +import kotlin.time.Duration.Companion.days +import kotlin.time.measureTime +import kotlin.time.measureTimedValue + +/** + * The plan's shape at scale: 5,000 tasks with 20 recurring series, read the way a + * smart list reads them — one `tasks(TaskQuery(includeCompleted = true))`, which + * includes expanding every series in memory. + * + * The assertion is a deliberately loose ceiling, so it catches a real regression + * rather than CI jitter; the printed numbers are what the check is actually for. + */ +@RunWith(AndroidJUnit4::class) +class TasksDatabasePerformanceTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + + private lateinit var db: TasksDatabase + private lateinit var source: RoomTasksDataSource + private var listId = 0L + + @Before + fun setUp() { + delete() + db = Room.databaseBuilder(context, TasksDatabase::class.java, DB) + .allowMainThreadQueries() + .build() + source = RoomTasksDataSource(db) + listId = source.createLocalList("Everything", 0xFF112233.toInt()) + } + + @After + fun tearDown() { + db.close() + delete() + } + + @Test + fun readsFiveThousandTasksWithTwentySeriesInsideTheBudget() { + val seeded = measureTime { seed() } + + // Discard the first read: it pays for statement compilation and page cache + // warming, which a running app has already paid. + source.tasks(TaskQuery(includeCompleted = true)) + val (tasks, elapsed) = measureTimedValue { + source.tasks(TaskQuery(includeCompleted = true)) + } + + println( + "[perf] $TASK_COUNT tasks / $SERIES_COUNT series -> ${tasks.size} occurrences " + + "in $elapsed (seed $seeded)", + ) + // Expansion is bounded twice over: the read window is 1 year back and 2 + // forward, and each series stops at ExpansionWindow.maxOccurrences (500), + // so the occurrence count cannot grow with the age of the series. + assertThat(tasks.size).isAtLeast(TASK_COUNT) + assertThat(elapsed.inWholeMilliseconds).isLessThan(CEILING_MILLIS) + } + + private fun seed() { + val anchor = Clock.System.now() - 30.days + val ids = ArrayList(TASK_COUNT) + db.runInTransaction { + repeat(TASK_COUNT) { index -> + ids += source.insertTask( + TaskForm(title = "Task $index", listId = listId, due = anchor + index.days), + ) + } + } + db.runInTransaction { + ids.take(SERIES_COUNT).forEach { id -> + val entity = db.tasks().entity(id)!! + db.tasks().update( + entity.copy(dtstart = anchor, due = anchor + 1.days, rrule = "FREQ=DAILY"), + ) + } + } + } + + private fun delete() { + val base = context.getDatabasePath(DB) + base.delete() + listOf("-wal", "-shm").forEach { File(base.path + it).delete() } + } + + private companion object { + const val DB = "performance-test.db" + const val TASK_COUNT = 5_000 + const val SERIES_COUNT = 20 + const val CEILING_MILLIS = 8_000L + } +} diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseRestoreTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseRestoreTest.kt new file mode 100644 index 0000000..962ddde --- /dev/null +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseRestoreTest.kt @@ -0,0 +1,155 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +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.TaskQuery +import de.jeanlucmakiola.agendula.domain.TaskForm +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File + +/** + * The Auto Backup restore path, on disk. + * + * Auto Backup copies database files without checkpointing, and Room runs in WAL + * mode — so `.db` alone can be a *stale* copy of a database whose recent writes + * are still in the `-wal` sidecar. `res/xml/backup_rules.xml` carries all three + * files and [DatabaseCheckpoint] truncates the log on `ON_STOP`; this asserts + * that both of those actually do what they claim, and that neither alone is an + * assumption. + * + * A file copy of a live database stands in for the backup transport — the + * transport is what Auto Backup does to these files, and it is not what is under + * test here. + */ +@RunWith(AndroidJUnit4::class) +class TasksDatabaseRestoreTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + + private lateinit var db: TasksDatabase + private lateinit var source: RoomTasksDataSource + private var listId = 0L + private var restored: TasksDatabase? = null + + @Before + fun setUp() { + delete(LIVE) + delete(BACKUP) + db = open(LIVE) + source = RoomTasksDataSource(db) + listId = source.createLocalList("Personal", 0xFF112233.toInt()) + } + + @After + fun tearDown() { + restored?.close() + db.close() + delete(LIVE) + delete(BACKUP) + } + + @Test + fun roomRunsInWalMode() { + // Everything below is only interesting because of this. + assertThat(journalMode()).isEqualTo("wal") + } + + @Test + fun aBackupOfTheDbFileAloneLosesWhateverIsStillInTheWal() { + write("checkpointed") + checkpoint() + write("only in the wal") + + backUp(withSidecars = false) + + assertThat(restore()).containsExactly("checkpointed") + } + + @Test + fun aBackupThatCarriesTheSidecarsKeepsTheLastWrite() { + write("checkpointed") + checkpoint() + write("only in the wal") + + backUp(withSidecars = true) + + assertThat(restore()).containsExactly("checkpointed", "only in the wal") + } + + @Test + fun checkpointingFirstMakesTheDbFileAloneEnough() { + write("checkpointed") + checkpoint() + write("last write") + + // What DatabaseCheckpoint runs on ON_STOP — the fallback for a restore + // that arrives without the sidecars. + checkpoint() + backUp(withSidecars = false) + + assertThat(restore()).containsExactly("checkpointed", "last write") + } + + // --- the moving parts ----------------------------------------------------- + + private fun open(name: String): TasksDatabase = + Room.databaseBuilder(context, TasksDatabase::class.java, name) + .allowMainThreadQueries() + .build() + + private fun write(title: String) { + source.insertTask(TaskForm(title = title, listId = listId)) + } + + private fun journalMode(): String = + db.openHelper.writableDatabase.query("PRAGMA journal_mode").use { cursor -> + cursor.moveToFirst() + cursor.getString(0).lowercase() + } + + /** [DatabaseCheckpoint]'s pragma, asserting it was not blocked by a reader. */ + private fun checkpoint() { + db.openHelper.writableDatabase.query("PRAGMA wal_checkpoint(TRUNCATE)").use { cursor -> + cursor.moveToFirst() + assertThat(cursor.getInt(0)).isEqualTo(0) + } + } + + /** Copies the live database the way Auto Backup would: no checkpoint, files as they lie. */ + private fun backUp(withSidecars: Boolean) { + delete(BACKUP) + val live = context.getDatabasePath(LIVE) + val backup = context.getDatabasePath(BACKUP) + live.copyTo(backup, overwrite = true) + if (!withSidecars) return + SIDECARS.forEach { suffix -> + val from = File(live.path + suffix) + if (from.exists()) from.copyTo(File(backup.path + suffix), overwrite = true) + } + } + + /** Opens the copy as a fresh install would and reports the task titles that survived. */ + private fun restore(): List { + restored?.close() + val database = open(BACKUP).also { restored = it } + return RoomTasksDataSource(database).tasks(TaskQuery(includeCompleted = true)).map { it.title } + } + + private fun delete(name: String) { + val base = context.getDatabasePath(name) + base.delete() + SIDECARS.forEach { File(base.path + it).delete() } + } + + private companion object { + const val LIVE = "restore-live.db" + const val BACKUP = "restore-backup.db" + val SIDECARS = listOf("-wal", "-shm") + } +} diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseTest.kt new file mode 100644 index 0000000..6095383 --- /dev/null +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseTest.kt @@ -0,0 +1,274 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +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.domain.TaskStatus +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.time.Instant + +/** + * The schema, exercised through the DAOs. Instrumented rather than JVM because + * the app's unit tests are plain JUnit 5 with no Robolectric, and Room needs a + * real SQLite. + */ +@RunWith(AndroidJUnit4::class) +class TasksDatabaseTest { + + private lateinit var db: TasksDatabase + private lateinit var lists: TaskListDao + private lateinit var tasks: TaskDao + private lateinit var alarms: TaskAlarmDao + private lateinit var accounts: AccountDao + + @Before + fun setUp() { + db = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + TasksDatabase::class.java, + ).allowMainThreadQueries().build() + lists = db.taskLists() + tasks = db.tasks() + alarms = db.alarms() + accounts = db.accounts() + } + + @After + fun tearDown() = db.close() + + private fun newList(name: String = "Groceries", accountId: Long? = null): Long = + lists.insert(TaskListEntity(name = name, color = 0xFF00FF00.toInt(), accountId = accountId)) + + private fun newTask( + listId: Long, + uid: String = "uid-${counter++}", + title: String? = "Buy milk", + status: TaskStatus = TaskStatus.NEEDS_ACTION, + parentId: Long? = null, + masterId: Long? = null, + recurrenceId: Instant? = null, + ): Long = tasks.insert( + TaskEntity( + listId = listId, + uid = uid, + title = title, + status = status, + parentId = parentId, + masterId = masterId, + recurrenceId = recurrenceId, + ), + ) + + @Test + fun writesAndReadsAListWithItsTasks() { + val accountId = accounts.insert(AccountEntity(displayName = "Fastmail")) + val listId = newList(accountId = accountId) + val due = Instant.fromEpochMilliseconds(1_700_000_000_000) + val taskId = tasks.insert( + TaskEntity( + listId = listId, + uid = "uid-1", + title = "Buy milk", + description = "2%", + due = due, + priority = 3, + status = TaskStatus.IN_PROCESS, + percentComplete = 40, + ), + ) + + val list = lists.lists().single() + assertThat(list.list.id).isEqualTo(listId) + assertThat(list.list.name).isEqualTo("Groceries") + assertThat(list.accountDisplayName).isEqualTo("Fastmail") + + val row = tasks.task(taskId)!! + assertThat(row.task.title).isEqualTo("Buy milk") + assertThat(row.task.due).isEqualTo(due) + // Stored raw: an off-bucket PRIORITY must come back as it went in. + assertThat(row.task.priority).isEqualTo(3) + assertThat(row.task.status).isEqualTo(TaskStatus.IN_PROCESS) + assertThat(row.task.percentComplete).isEqualTo(40) + assertThat(row.listName).isEqualTo("Groceries") + assertThat(row.accountDisplayName).isEqualTo("Fastmail") + } + + @Test + fun readsTasksOfOneListAndHidesClosedOnesUnlessAsked() { + val a = newList("A") + val b = newList("B") + newTask(a, title = "open") + newTask(a, title = "done", status = TaskStatus.COMPLETED) + newTask(a, title = "cancelled", status = TaskStatus.CANCELLED) + newTask(b, title = "elsewhere") + + assertThat(tasks.tasks(a, includeCompleted = false).map { it.task.title }) + .containsExactly("open") + assertThat(tasks.tasks(a, includeCompleted = true)).hasSize(3) + assertThat(tasks.tasks(null, includeCompleted = true)).hasSize(4) + } + + @Test + fun readsSubtasksByParent() { + val listId = newList() + val parent = newTask(listId, title = "parent") + newTask(listId, title = "child", parentId = parent) + + assertThat(tasks.subtasks(parent).map { it.task.title }).containsExactly("child") + } + + @Test + fun hidesTombstonesFromReadsAndExports() { + val listId = newList() + val taskId = newTask(listId) + tasks.markDeleted(taskId, Instant.fromEpochMilliseconds(1)) + + assertThat(tasks.tasks(listId, includeCompleted = true)).isEmpty() + assertThat(tasks.task(taskId)).isNull() + assertThat(tasks.exportTasks(listId)).isEmpty() + assertThat(tasks.entity(taskId)).isNotNull() + } + + @Test + fun keepsOverridesOutOfTheMasterReads() { + val listId = newList() + val master = newTask(listId, uid = "series") + val override = newTask( + listId, + uid = "series", + masterId = master, + recurrenceId = Instant.fromEpochMilliseconds(5_000), + ) + + assertThat(tasks.tasks(listId, includeCompleted = true).map { it.task.id }) + .containsExactly(master) + assertThat(tasks.overrides(master).map { it.id }).containsExactly(override) + assertThat(tasks.allOverrides(listId).map { it.id }).containsExactly(override) + assertThat(tasks.override(master, Instant.fromEpochMilliseconds(5_000))?.id) + .isEqualTo(override) + assertThat(tasks.exportTasks(listId).map { it.id }).containsExactly(master) + } + + // --- cascades ------------------------------------------------------------- + + @Test + fun deletingAListDeletesItsTasks() { + val listId = newList() + val taskId = newTask(listId) + + lists.delete(listId) + + assertThat(tasks.entity(taskId)).isNull() + } + + @Test + fun deletingASeriesDeletesItsOverrides() { + val listId = newList() + val master = newTask(listId, uid = "series") + val override = newTask( + listId, + uid = "series", + masterId = master, + recurrenceId = Instant.fromEpochMilliseconds(5_000), + ) + + tasks.delete(master) + + assertThat(tasks.entity(override)).isNull() + } + + @Test + fun deletingAParentPromotesItsSubtasks() { + val listId = newList() + val parent = newTask(listId, title = "parent") + val child = newTask(listId, title = "child", parentId = parent) + + tasks.delete(parent) + + val promoted = tasks.entity(child) + assertThat(promoted).isNotNull() + assertThat(promoted!!.parentId).isNull() + } + + @Test + fun deletingATaskDeletesItsAlarms() { + val listId = newList() + val taskId = newTask(listId) + alarms.replaceForTask(taskId, TaskAlarmEntity(taskId = taskId, minutesBefore = 15)) + assertThat(alarms.all()).hasSize(1) + + tasks.delete(taskId) + + assertThat(alarms.all()).isEmpty() + } + + @Test + fun deletingAnAccountDetachesItsListsInsteadOfDeletingThem() { + val accountId = accounts.insert(AccountEntity(displayName = "Fastmail")) + val listId = newList(accountId = accountId) + + accounts.delete(accountId) + + assertThat(lists.entity(listId)!!.accountId).isNull() + } + + @Test + fun replacingAnAlarmLeavesOnlyTheNewOne() { + val listId = newList() + val taskId = newTask(listId) + alarms.replaceForTask(taskId, TaskAlarmEntity(taskId = taskId, minutesBefore = 15)) + alarms.replaceForTask(taskId, TaskAlarmEntity(taskId = taskId, minutesBefore = 30)) + + assertThat(alarms.forTask(taskId).map { it.minutesBefore }).containsExactly(30) + assertThat(alarms.forTask(taskId).single().reference).isEqualTo(AlarmReference.DUE) + + alarms.replaceForTask(taskId, null) + assertThat(alarms.forTask(taskId)).isEmpty() + } + + // --- the unique index ----------------------------------------------------- + + @Test + fun anOverrideMayShareItsMastersUid() { + val listId = newList() + val master = newTask(listId, uid = "series") + newTask(listId, uid = "series", masterId = master, recurrenceId = Instant.fromEpochMilliseconds(1)) + newTask(listId, uid = "series", masterId = master, recurrenceId = Instant.fromEpochMilliseconds(2)) + + assertThat(tasks.overrides(master)).hasSize(2) + } + + @Test + fun rejectsTwoOverridesOfTheSameOccurrence() { + val listId = newList() + val master = newTask(listId, uid = "series") + val at = Instant.fromEpochMilliseconds(1) + newTask(listId, uid = "series", masterId = master, recurrenceId = at) + + val failure = runCatching { + newTask(listId, uid = "series", masterId = master, recurrenceId = at) + }.exceptionOrNull() + + assertThat(failure).isNotNull() + assertThat(failure!!.message).contains("UNIQUE") + } + + @Test + fun theSameUidMayExistInAnotherList() { + val a = newList("A") + val b = newList("B") + newTask(a, uid = "shared") + newTask(b, uid = "shared") + + assertThat(tasks.byUid(a, "shared")).isNotNull() + assertThat(tasks.byUid(b, "shared")).isNotNull() + } + + private companion object { + var counter = 0 + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6d37b60..a50a376 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,9 +2,15 @@ - + runtime by the permission flow, and only once the user has actually + 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. --> @@ -74,8 +80,11 @@ - + diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/AgendulaApp.kt b/app/src/main/java/de/jeanlucmakiola/agendula/AgendulaApp.kt index 000182b..517bfc3 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/AgendulaApp.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/AgendulaApp.kt @@ -1,18 +1,22 @@ package de.jeanlucmakiola.agendula import android.app.Application +import androidx.lifecycle.ProcessLifecycleOwner import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors import dagger.hilt.android.HiltAndroidApp import dagger.hilt.components.SingletonComponent +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.StartupGate +import de.jeanlucmakiola.agendula.data.tasks.room.DatabaseCheckpoint import de.jeanlucmakiola.floret.crash.CrashConfig import de.jeanlucmakiola.floret.crash.CrashReporter import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicBoolean /** * Application entry point. Registered as android:name=".AgendulaApp". Besides @@ -35,17 +39,42 @@ class AgendulaApp : Application() { issueTitle = getString(R.string.crash_report_issue_title), ), ) - val scheduler = EntryPointAccessors - .fromApplication(this, ReminderEntryPoint::class.java) - .reminderScheduler() - CoroutineScope(SupervisorJob() + Dispatchers.Default).launch { - runCatching { scheduler.sync() } + val entryPoint = EntryPointAccessors.fromApplication(this, AppEntryPoint::class.java) + val scheduler = entryPoint.reminderScheduler() + // Mirror the stored storage mode into ProviderResolver and import a + // v0.3.x install's tasks, both before anything reads a store. + val startupGate = entryPoint.startupGate() + val scope = entryPoint.applicationScope() + // An alarm is armed off whichever store was active when it was scheduled, + // so a switch has to rebuild the set. Armed only once startup's own + // null -> stored transition is past, which the launch sync below covers. + val started = AtomicBoolean(false) + entryPoint.providerResolver().onModeChanged { + if (started.get()) scope.launch { runCatching { scheduler.sync() } } + } + startupGate.start() + ProcessLifecycleOwner.get().lifecycle.addObserver(entryPoint.databaseCheckpoint()) + scope.launch { + // Wait for the stored mode and the import to land first. Rescheduling + // alarms against whichever store autoMode happens to pick would arm + // them off the wrong one — or off an empty one, mid-import. + runCatching { + startupGate.awaitReady() + started.set(true) + scheduler.sync() + } } } @EntryPoint @InstallIn(SingletonComponent::class) - interface ReminderEntryPoint { + interface AppEntryPoint { fun reminderScheduler(): ReminderScheduler + fun startupGate(): StartupGate + fun providerResolver(): ProviderResolver + + @ApplicationScope + fun applicationScope(): CoroutineScope + fun databaseCheckpoint(): DatabaseCheckpoint } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/demo/DemoSeeder.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/demo/DemoSeeder.kt index f3234ff..a9b8f6a 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/demo/DemoSeeder.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/demo/DemoSeeder.kt @@ -50,7 +50,7 @@ class DemoSeeder @Inject constructor( repository.createTask(TaskForm(title = "Sketch the Agendula app icon", listId = listId)) val done = repository.createTask(TaskForm(title = "Renew domain name", listId = listId, due = at(ts - 2 * day))) - repository.setCompleted(done, completed = true) + repository.setCompleted(done, occurrenceStart = null, completed = true) } private companion object { diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt index 7e668e7..b8f3448 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt @@ -3,6 +3,8 @@ package de.jeanlucmakiola.agendula.data.di import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences +import androidx.room.Room +import androidx.room.RoomDatabase import androidx.datastore.preferences.preferencesDataStore import dagger.Binds import dagger.Module @@ -10,12 +12,21 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import de.jeanlucmakiola.agendula.data.tasks.AndroidProviderEnvironment import de.jeanlucmakiola.agendula.data.tasks.AndroidTasksDataSource +import de.jeanlucmakiola.agendula.data.tasks.ModeRoutingTasksDataSource +import de.jeanlucmakiola.agendula.data.tasks.ProviderEnvironment +import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepositoryImpl +import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource +import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import javax.inject.Provider import javax.inject.Singleton private val Context.agendulaDataStore: DataStore by preferencesDataStore( @@ -28,11 +39,11 @@ abstract class DataBindModule { @Binds @Singleton - abstract fun bindTasksDataSource(impl: AndroidTasksDataSource): TasksDataSource + abstract fun bindTasksRepository(impl: TasksRepositoryImpl): TasksRepository @Binds @Singleton - abstract fun bindTasksRepository(impl: TasksRepositoryImpl): TasksRepository + abstract fun bindProviderEnvironment(impl: AndroidProviderEnvironment): ProviderEnvironment } @Module @@ -44,7 +55,41 @@ object DataProvideModule { fun provideDataStore(@ApplicationContext context: Context): DataStore = context.agendulaDataStore + @Provides + @Singleton + fun provideTasksDatabase(@ApplicationContext context: Context): TasksDatabase = + Room.databaseBuilder(context, TasksDatabase::class.java, TasksDatabase.NAME) + // Room's default, stated rather than assumed: Auto Backup copies files + // without checkpointing, so a `-wal` sidecar can hold writes the + // backed-up `.db` does not. The backup rules carry all three files and + // the app checkpoints on ON_STOP. + .setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING) + .build() + + /** + * The active store, chosen by [StorageMode]. + * + * Resolved per injection point rather than bound once, because the mode is a + * user setting that [de.jeanlucmakiola.agendula.data.tasks.StorageModeHolder] + * can change while the process lives. Both implementations are singletons, so + * this picks between two long-lived objects rather than building either. + */ + @Provides + @Singleton + fun provideTasksDataSource( + resolver: ProviderResolver, + room: Provider, + external: Provider, + ): TasksDataSource = ModeRoutingTasksDataSource(resolver, room, external) + @Provides @IoDispatcher fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO + + @Provides + @Singleton + @ApplicationScope + fun provideApplicationScope(): CoroutineScope = + // SupervisorJob so one failing collector can't take the others down with it. + CoroutineScope(SupervisorJob() + Dispatchers.Default) } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/Qualifiers.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/Qualifiers.kt index 6be87bc..e42dfcf 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/Qualifiers.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/Qualifiers.kt @@ -6,3 +6,13 @@ import javax.inject.Qualifier @Qualifier @Retention(AnnotationRetention.BINARY) annotation class IoDispatcher + +/** + * Marks the process-lifetime [kotlinx.coroutines.CoroutineScope] — for work that + * outlives any screen and has nothing to be cancelled by, such as keeping the + * selected storage mode mirrored out of DataStore. It is never cancelled, so + * don't launch anything unbounded in it. + */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class ApplicationScope diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/export/ExportWriter.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/export/ExportWriter.kt new file mode 100644 index 0000000..03f43d5 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/export/ExportWriter.kt @@ -0,0 +1,132 @@ +package de.jeanlucmakiola.agendula.data.export + +import android.content.Context +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import dagger.hilt.android.qualifiers.ApplicationContext +import de.jeanlucmakiola.agendula.data.di.IoDispatcher +import de.jeanlucmakiola.agendula.domain.export.ExportDocument +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import java.io.IOException +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import javax.inject.Inject +import javax.inject.Singleton + +/** Where an export ended up, for the UI to report. */ +data class ExportResult(val fileCount: Int, val taskListNames: List) + +/** + * Why an export failed, as a value rather than a message: the UI ships in eleven + * locales, so the wording has to come from a string resource. + */ +enum class ExportFailure { + FOLDER_UNAVAILABLE, + FOLDER_NOT_WRITABLE, + CANNOT_CREATE_FILE, + LOST_ACCESS, + WRITE_FAILED, +} + +/** The export could not be written. */ +class ExportFailedException( + val failure: ExportFailure, + cause: Throwable? = null, +) : IOException(failure.name, cause) + +/** + * Writes [ExportDocument]s to a user-chosen location through the Storage Access + * Framework. + * + * No storage permission anywhere: SAF hands us a `Uri` the user picked + * themselves, which is both the modern approach and the only one that still works + * on scoped storage. The caller owns launching `ACTION_CREATE_DOCUMENT` (for + * [writeZip]) or `ACTION_OPEN_DOCUMENT_TREE` (for [writeToTree]) and passes the + * result here. + * + * Marked in `docs/STORAGE-AND-SYNC.md` as floret-kit material — the plumbing is + * not task-domain and Calendula will want the same thing. Kept app-local for now + * on the kit's own stated principle of not extracting until a second consumer + * actually exists; the seam is here, so moving it later is a file move. + */ +@Singleton +class ExportWriter @Inject constructor( + @ApplicationContext private val context: Context, + @IoDispatcher private val io: CoroutineDispatcher, +) { + + /** + * Writes every document into [treeUri], a directory the user picked. + * + * A same-named file is truncated and rewritten in place rather than deleted + * and recreated: SAF would otherwise append " (1)" and turn the folder into + * an unusable pile of snapshots, and a delete that is not followed by a + * successful create loses the previous export outright. + * + * The directory is listed once. `DocumentFile.findFile` queries the whole + * tree per call, so looking each name up in the loop is one full + * cross-process directory scan per list. + */ + suspend fun writeToTree(treeUri: Uri, documents: List): ExportResult = + withContext(io) { + runCatching { + val tree = DocumentFile.fromTreeUri(context, treeUri) + ?: throw ExportFailedException(ExportFailure.FOLDER_UNAVAILABLE) + if (!tree.canWrite()) throw ExportFailedException(ExportFailure.FOLDER_NOT_WRITABLE) + val existing = tree.listFiles().associateBy { it.name } + + documents.forEach { document -> + val file = existing[document.fileName] + ?: tree.createFile(MIME_ICALENDAR, document.fileName) + ?: throw ExportFailedException(ExportFailure.CANNOT_CREATE_FILE) + write(file.uri, document.content) + } + }.getOrElse { throw asExportFailure(it) } + ExportResult(documents.size, documents.map { it.fileName }) + } + + /** + * Writes every document into a single zip at [target]. + * + * The one-file form, for sharing or for a backup the user filed somewhere + * themselves — one attachment rather than one per list. + */ + suspend fun writeZip(target: Uri, documents: List): ExportResult = + withContext(io) { + runCatching { + context.contentResolver.openOutputStream(target, "wt")?.use { raw -> + ZipOutputStream(raw.buffered()).use { zip -> + documents.forEach { document -> + zip.putNextEntry(ZipEntry(document.fileName)) + zip.write(document.content) + zip.closeEntry() + } + } + } ?: throw ExportFailedException(ExportFailure.WRITE_FAILED) + }.getOrElse { throw asExportFailure(it) } + ExportResult(documents.size, documents.map { it.fileName }) + } + + private fun write(target: Uri, bytes: ByteArray) { + runCatching { + // "wt" truncates. Without it a shorter export leaves the tail of the + // previous, longer one behind and produces a corrupt file. + context.contentResolver.openOutputStream(target, "wt")?.use { it.write(bytes) } + ?: throw ExportFailedException(ExportFailure.WRITE_FAILED) + }.getOrElse { throw asExportFailure(it) } + } + + private fun asExportFailure(cause: Throwable): Throwable = when (cause) { + is ExportFailedException -> cause + // A SAF grant can be revoked between the picker and the write (the volume + // was unmounted, the provider's process died, the user cleared the grant). + is SecurityException -> ExportFailedException(ExportFailure.LOST_ACCESS, cause) + is IOException -> ExportFailedException(ExportFailure.WRITE_FAILED, cause) + else -> cause + } + + private companion object { + const val MIME_ICALENDAR = "text/calendar" + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/export/TaskExporter.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/export/TaskExporter.kt new file mode 100644 index 0000000..dcf40a8 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/export/TaskExporter.kt @@ -0,0 +1,76 @@ +package de.jeanlucmakiola.agendula.data.export + +import de.jeanlucmakiola.agendula.data.di.IoDispatcher +import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource +import de.jeanlucmakiola.agendula.domain.export.ExportDocument +import de.jeanlucmakiola.agendula.domain.export.ExportList +import de.jeanlucmakiola.agendula.domain.export.ICalendarWriter +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Turns the user's task lists into `.ics` documents. + * + * Export is a v1 feature rather than a nicety because of where the data now + * lives: our own provider is inside the app's private storage, so in Local mode a + * user's tasks exist in exactly one place and uninstalling deletes them. On Play, + * where most people will never have a sync engine, that is the majority case. + * + * **One document per list**, because a list is a CalDAV collection and that is the + * unit every other client understands. Bundling everything into a single file + * would flatten the lists away, and list membership is not recoverable from a + * VTODO afterwards. + */ +@Singleton +class TaskExporter @Inject constructor( + private val dataSource: TasksDataSource, + @IoDispatcher private val io: CoroutineDispatcher, +) { + + /** + * Serialises [listIds] — every visible list when null. + * + * A list with no tasks still produces a document. An empty `.ics` is a real + * answer ("this list is empty"), whereas a missing file is indistinguishable + * from the export having gone wrong. + */ + suspend fun export(listIds: Set? = null): List = withContext(io) { + dataSource.taskLists() + .filter { listIds == null || it.id in listIds } + .map { list -> + val document = ExportList( + listId = list.id, + name = list.name, + accountName = list.accountName, + tasks = dataSource.exportTasks(list.id), + ) + ExportDocument( + fileName = fileNameFor(list.name, list.id), + content = ICalendarWriter.write(document).toByteArray(Charsets.UTF_8), + ) + } + } + + companion object { + + /** + * A file name derived from the list name, safe on every filesystem the + * user might pick through SAF (including FAT32 on an SD card). + * + * The list id is appended rather than trusted to be redundant: two lists on + * different accounts may share a name, and two exports landing on the same + * file would silently lose one of them. + */ + fun fileNameFor(listName: String, listId: Long): String { + val safe = listName + .map { if (it.isLetterOrDigit() || it == '-' || it == '_') it else '-' } + .joinToString("") + .trim('-') + .take(60) + .ifBlank { "list" } + return "$safe-$listId.ics" + } + } +} 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 4508baf..2702a6c 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 @@ -8,6 +8,7 @@ import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey +import de.jeanlucmakiola.agendula.data.tasks.StorageMode import de.jeanlucmakiola.agendula.domain.TaskFormField import de.jeanlucmakiola.floret.reminders.ReminderOverride import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec @@ -82,6 +83,31 @@ class SettingsPrefs @Inject constructor( suspend fun setReminderLeadMinutes(minutes: Int) = dataStore.edit { it[REMINDER_LEAD] = minutes } + /** + * Which task store backs the app, or `null` while the user has not chosen — + * which is the normal state, since most people never open Settings. + * + * Kept out of [Settings] on purpose. Everything in there is a rendering + * preference collected by the UI; this one selects an authority in the data + * layer, is read on paths that must not wait for a whole settings object, and + * `null` genuinely means "undecided" rather than "default" — the difference + * matters, because undecided is what lets `ProviderResolver.autoMode` keep an + * upgrading Posture A user pointed at the provider that holds their data. + */ + val storageMode: Flow = dataStore.data.map { p -> + when (val stored = p[STORAGE_MODE]) { + null -> null + // 0.3.x's value for the bundled dmfs provider. That store is gone and + // its data was imported into OWN, so read it as OWN rather than + // letting it fall through to autoMode — someone who chose local + // storage explicitly would otherwise be sent to an external provider. + "LOCAL" -> StorageMode.OWN + else -> runCatching { StorageMode.valueOf(stored) }.getOrNull() + } + } + + suspend fun setStorageMode(mode: StorageMode) = dataStore.edit { it[STORAGE_MODE] = mode.name } + /** One-time reminder onboarding gate; false until the step has been shown. */ val reminderOnboardingDone: Flow = dataStore.data.map { it[REMINDER_ONBOARDING_DONE] ?: false } @@ -113,6 +139,7 @@ class SettingsPrefs @Inject constructor( val SHOW_ADD_SUBTASK_ROW = booleanPreferencesKey("show_add_subtask_row") val BOTTOM_ADD_BAR = booleanPreferencesKey("bottom_add_bar") val REMINDER_ONBOARDING_DONE = booleanPreferencesKey("reminder_onboarding_done") + val STORAGE_MODE = stringPreferencesKey("storage_mode") val LIST_REMINDER_OVERRIDE = stringPreferencesKey("list_reminder_override") val DEFAULT_EDIT_FIELDS = stringSetPreferencesKey("default_edit_fields") } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/DueReminderReceiver.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/DueReminderReceiver.kt index dba31d7..01aac13 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/DueReminderReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/DueReminderReceiver.kt @@ -3,6 +3,7 @@ package de.jeanlucmakiola.agendula.data.reminders import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import androidx.core.net.toUri import dagger.hilt.android.AndroidEntryPoint import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource @@ -45,7 +46,14 @@ class DueReminderReceiver : BroadcastReceiver() { companion object { private const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID" - fun intent(context: Context, taskId: Long): Intent = - Intent(context, DueReminderReceiver::class.java).putExtra(EXTRA_TASK_ID, taskId) + /** + * [triggerAt] rides in the intent *data*, not just an extra: PendingIntent + * identity ignores extras, so two occurrences of the same recurring task + * would otherwise collapse into one alarm under FLAG_UPDATE_CURRENT. + */ + fun intent(context: Context, taskId: Long, triggerAt: Long): Intent = + Intent(context, DueReminderReceiver::class.java) + .setData("agendula://reminder/$taskId/$triggerAt".toUri()) + .putExtra(EXTRA_TASK_ID, taskId) } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ProviderChangeReceiver.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ProviderChangeReceiver.kt index 82c491b..c0c74e9 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ProviderChangeReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ProviderChangeReceiver.kt @@ -3,7 +3,9 @@ package de.jeanlucmakiola.agendula.data.reminders import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.os.SystemClock import dagger.hilt.android.AndroidEntryPoint +import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -20,10 +22,25 @@ import javax.inject.Inject class ProviderChangeReceiver : BroadcastReceiver() { @Inject lateinit var scheduler: ReminderScheduler + @Inject lateinit var providerResolver: ProviderResolver private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) override fun onReceive(context: Context, intent: Intent) { + // The receiver has to stay exported to hear the provider's broadcast, and + // the sender holds no permission we could require — so validate the + // broadcast itself. Without this, any installed app can spam a full + // re-sync (an unbounded provider read) by firing a matching intent. + if (intent.action != Intent.ACTION_PROVIDER_CHANGED) return + val authority = providerResolver.resolve()?.authority ?: return + if (intent.data?.host != authority) return + // External sync can fire these in bursts; one re-sync per burst is plenty. + val now = SystemClock.elapsedRealtime() + synchronized(Companion) { + if (now - lastSyncAt < MIN_SYNC_INTERVAL_MS) return + lastSyncAt = now + } + val pending = goAsync() scope.launch { try { @@ -33,4 +50,11 @@ class ProviderChangeReceiver : BroadcastReceiver() { } } } + + private companion object { + const val MIN_SYNC_INTERVAL_MS = 10_000L + + @Volatile + var lastSyncAt = -MIN_SYNC_INTERVAL_MS + } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderScheduler.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderScheduler.kt index 4cea316..58467e0 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderScheduler.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderScheduler.kt @@ -12,15 +12,18 @@ import de.jeanlucmakiola.agendula.data.tasks.TaskQuery import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import javax.inject.Inject import javax.inject.Singleton /** - * The self-scheduled due-reminder engine. Tasks providers don't deliver - * reminders, so Agendula reads upcoming due tasks and arms one exact [AlarmManager] - * alarm each, within a rolling window. Re-run on app start, boot and provider - * change; it diffs against [ScheduledReminderStore] so only changed alarms move. + * The self-scheduled due-reminder engine. Nothing else delivers task reminders — + * not the platform, not a tasks provider — so Agendula reads upcoming due tasks + * and arms one exact [AlarmManager] alarm each, within a rolling window. Re-run + * on app start, on boot, on a store switch, and on an external provider change; + * it diffs against [ScheduledReminderStore] so only changed alarms move. */ @Singleton class ReminderScheduler @Inject constructor( @@ -31,47 +34,89 @@ class ReminderScheduler @Inject constructor( private val providerResolver: ProviderResolver, @IoDispatcher private val io: CoroutineDispatcher, ) { - suspend fun sync() = withContext(io) { - val provider = providerResolver.resolve() + private val syncLock = Mutex() + + /** + * Diff the armed alarms against the store and move only what changed. + * + * Serialised: the diff is a read-modify-write over [ScheduledReminderStore], + * and callers overlap (a store switch fires this while the launch sync may + * still be running). Two interleaved runs would each write their own set as + * the whole truth, leaving the other's alarms armed but unrecorded — never + * cancelled, and firing against the wrong store's task ids. + */ + suspend fun sync() = withContext(io) { syncLock.withLock { syncLocked() } } + + private suspend fun syncLocked() { val settings = settingsPrefs.settings.first() - if (provider == null || !providerResolver.hasPermission(provider) || !settings.remindersEnabled) { + // Gate on whether the store is readable, not on whether a provider + // resolves: our own store deliberately resolves to no provider, so the + // latter clears every reminder in the default mode. + if (!settings.remindersEnabled || !providerResolver.canReadStore()) { clearAll() - return@withContext + return } val now = System.currentTimeMillis() val horizon = now + WINDOW_MS val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) } - .getOrElse { return@withContext } + .getOrElse { return } + + // One reminder per *occurrence*: a recurring series yields a row per + // occurrence, all sharing a taskId, so this is a Set rather than a + // taskId-keyed Map — keying by task would collapse a daily recurring task + // down to one arbitrary reminder. + // Per-task leads. One query for all of them. + val perTask = runCatching { dataSource.alarms() }.getOrElse { emptyMap() } val desired = tasks .filter { !it.isClosed && it.due != null } .mapNotNull { task -> - // The task's list may override the global lead, or opt out entirely - // (override = null), in which case it gets no reminder at all. - val lead = settings.reminderLeadFor(task.listId) ?: return@mapNotNull null - task.taskId to (task.due!!.toEpochMilliseconds() - lead.coerceAtLeast(0) * 60_000L) + // A reminder set on the task itself wins; otherwise the task's list + // may override the global lead, or opt out entirely (override = + // null), in which case it gets no reminder at all. + val reminder = perTask[task.taskId] + val lead = reminder?.minutesBefore + ?: settings.reminderLeadFor(task.listId) + ?: return@mapNotNull null + // 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* — firing those off the due date is silently wrong + // for every task whose start and due differ. + val anchor = if (reminder?.fromStart == true) task.start ?: task.due!! else task.due!! + ScheduledReminder( + taskId = task.taskId, + triggerAt = anchor.toEpochMilliseconds() - lead.coerceAtLeast(0) * 60_000L, + ) } - .toMap() - .filterValues { it in now..horizon } + // The lower bound trails `now` so a reminder missed while the device was + // off still fires once on boot instead of being silently dropped — + // setExactAndAllowWhileIdle delivers a past trigger immediately. Anything + // already armed stays armed (the diff below), so it can't re-fire. + .filter { it.triggerAt in (now - MISSED_GRACE_MS)..horizon } + .toSet() val previous = store.all() - (previous.keys - desired.keys).forEach { cancel(it) } - desired.forEach { (taskId, triggerAt) -> - if (previous[taskId] != triggerAt) schedule(taskId, triggerAt) - } + (previous - desired).forEach { cancel(it) } + (desired - previous).forEach { schedule(it) } store.replace(desired) } private fun alarmManager(): AlarmManager = context.getSystemService(AlarmManager::class.java) - private fun pendingIntent(taskId: Long, create: Boolean): PendingIntent? { + private fun pendingIntent(reminder: ScheduledReminder, create: Boolean): PendingIntent? { val flags = (if (create) PendingIntent.FLAG_UPDATE_CURRENT else PendingIntent.FLAG_NO_CREATE) or PendingIntent.FLAG_IMMUTABLE - return PendingIntent.getBroadcast(context, taskId.toInt(), DueReminderReceiver.intent(context, taskId), flags) + return PendingIntent.getBroadcast( + context, + reminder.requestCode, + DueReminderReceiver.intent(context, reminder.taskId, reminder.triggerAt), + flags, + ) } - private fun schedule(taskId: Long, triggerAt: Long) { - val pi = pendingIntent(taskId, create = true) ?: return + private fun schedule(reminder: ScheduledReminder) { + val triggerAt = reminder.triggerAt + val pi = pendingIntent(reminder, create = true) ?: return val am = alarmManager() val canExact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms() if (canExact) { @@ -81,19 +126,21 @@ class ReminderScheduler @Inject constructor( } } - private fun cancel(taskId: Long) { - pendingIntent(taskId, create = false)?.let { + private fun cancel(reminder: ScheduledReminder) { + pendingIntent(reminder, create = false)?.let { alarmManager().cancel(it) it.cancel() } } private suspend fun clearAll() { - store.all().keys.forEach { cancel(it) } - store.replace(emptyMap()) + store.all().forEach { cancel(it) } + store.replace(emptySet()) } private companion object { const val WINDOW_MS = 30L * 24 * 60 * 60 * 1000 // 30 days + /** How long after its trigger a missed reminder is still worth firing. */ + const val MISSED_GRACE_MS = 6L * 60 * 60 * 1000 // 6 hours } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ScheduledReminderStore.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ScheduledReminderStore.kt index 1331b0a..a802258 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ScheduledReminderStore.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ScheduledReminderStore.kt @@ -9,25 +9,38 @@ import javax.inject.Inject import javax.inject.Singleton /** - * Remembers which task reminders are currently scheduled (taskId → trigger time), - * so [ReminderScheduler] can diff against a fresh computation and cancel only the - * alarms that changed. Persisted in DataStore as a set of `taskId|trigger` strings. + * One armed alarm. A recurring task has many occurrences sharing a [taskId], so + * the trigger time is part of the identity — keying by task alone would collapse + * a daily task down to a single reminder. + */ +data class ScheduledReminder(val taskId: Long, val triggerAt: Long) { + /** + * Request code for this alarm's PendingIntent. Derived from both fields so + * sibling occurrences don't share (and overwrite) one alarm slot. + */ + val requestCode: Int get() = (taskId * 31 + triggerAt).hashCode() +} + +/** + * Remembers which task reminders are currently armed, so [ReminderScheduler] can + * diff against a fresh computation and touch only the alarms that changed. + * Persisted in DataStore as a set of `taskId|trigger` strings. */ @Singleton class ScheduledReminderStore @Inject constructor( private val dataStore: DataStore, ) { - suspend fun all(): Map = + suspend fun all(): Set = dataStore.data.first()[KEY].orEmpty().mapNotNull { entry -> val parts = entry.split('|') val id = parts.getOrNull(0)?.toLongOrNull() val at = parts.getOrNull(1)?.toLongOrNull() - if (id != null && at != null) id to at else null - }.toMap() + if (id != null && at != null) ScheduledReminder(id, at) else null + }.toSet() - suspend fun replace(scheduled: Map) { + suspend fun replace(scheduled: Set) { dataStore.edit { prefs -> - prefs[KEY] = scheduled.entries.map { "${it.key}|${it.value}" }.toSet() + prefs[KEY] = scheduled.map { "${it.taskId}|${it.triggerAt}" }.toSet() } } 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 1ddbfa6..ca7077d 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 @@ -11,13 +11,16 @@ import android.os.Looper import dagger.hilt.android.qualifiers.ApplicationContext 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.domain.Task import de.jeanlucmakiola.agendula.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskList +import de.jeanlucmakiola.agendula.domain.export.ExportTask import java.time.ZoneId import javax.inject.Inject import javax.inject.Singleton +import kotlin.time.Instant /** * The only class that knows about the ContentResolver, [TasksContract] and the @@ -83,6 +86,26 @@ class AndroidTasksDataSource @Inject constructor( } ?: emptyList() } + override fun exportTasks(listId: Long): List { + // projection = null for the same reason queryInstances uses it: the tasks + // table's shape varies across provider versions, and the by-name mapper + // reads what's there. + val uri = TasksContract.tasksUri(authority()) + return resolver.query( + uri, + null, + // _deleted marks a row awaiting a sync round-trip. It's gone as far as + // the user is concerned, so exporting it would resurrect deleted tasks + // in the backup. + "${Tasks.LIST_ID} = ? AND (${Tasks.DELETED} IS NULL OR ${Tasks.DELETED} = 0)", + arrayOf(listId.toString()), + null, + )?.use { c -> + val reader = CursorColumnReader(c) + buildList { while (c.moveToNext()) add(TaskMapper.exportTask(reader)) } + } ?: emptyList() + } + // --- writes --------------------------------------------------------------- override fun insertTask(form: TaskForm): Long { @@ -98,12 +121,109 @@ class AndroidTasksDataSource @Inject constructor( if (rows == 0) throw TaskWriteFailedException("update task $taskId") } + override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) { + val instanceId = instanceIdFor(taskId, occurrenceStart) + ?: throw TaskWriteFailedException("update instance $taskId@$occurrenceStart: no such occurrence") + val values = TaskWriteMapper.instanceValues(form, ZoneId.systemDefault().id) + val uri = TasksContract.instanceUri(authority(), instanceId) + val rows = resolver.update(uri, values.toContentValues(), null, null) + if (rows == 0) throw TaskWriteFailedException("update instance $instanceId") + } + + /** + * The provider's instance row id for one occurrence. + * + * The seam addresses occurrences by `(taskId, occurrenceStart)`; writing + * through the instances URI still needs the row id, so it is looked up here + * rather than carried around above the data layer. Selection is on `task_id` + * only — the anchor is matched in Kotlin because the column that holds it + * (`instance_original_time`) is missing on older provider schemas, where a + * WHERE clause naming it would throw instead of falling back. + */ + private fun instanceIdFor(taskId: Long, occurrenceStart: Instant): Long? { + val uri = TasksContract.instancesUri(authority()) + val selection = "${Instances.TASK_ID} = ?" + return resolver.query(uri, null, selection, arrayOf(taskId.toString()), null)?.use { c -> + val reader = CursorColumnReader(c) + while (c.moveToNext()) { + if (TaskMapper.occurrenceAnchor(reader) == occurrenceStart) { + return@use reader.getLong(Tasks.ID) + } + } + null + } + } + + 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 — and delete+insert + // means we never have to track property_id. + resolver.delete( + uri, + "${Properties.TASK_ID} = ? AND ${Properties.MIMETYPE} = ?", + arrayOf(taskId.toString(), TasksContract.Alarm.MIMETYPE), + ) + if (minutesBeforeDue != null) { + resolver.insert(uri, TaskWriteMapper.alarmValues(taskId, minutesBeforeDue).toContentValues()) + ?: throw TaskWriteFailedException("set alarm for task $taskId") + } + } + + override fun alarms(): Map { + val uri = TasksContract.propertiesUri(authority()) + val projection = arrayOf( + Properties.TASK_ID, + TasksContract.Alarm.MINUTES_BEFORE, + TasksContract.Alarm.REFERENCE, + ) + return resolver.query( + uri, + projection, + "${Properties.MIMETYPE} = ?", + arrayOf(TasksContract.Alarm.MIMETYPE), + null, + )?.use { c -> + val reader = CursorColumnReader(c) + buildMap { + while (c.moveToNext()) { + val id = reader.getLong(Properties.TASK_ID) + val minutes = reader.getInt(TasksContract.Alarm.MINUTES_BEFORE) + val reference = reader.getInt(TasksContract.Alarm.REFERENCE) + if (id != null && minutes != null) { + put( + id, + TaskReminder( + minutesBefore = minutes, + fromStart = reference == TasksContract.Alarm.REFERENCE_START, + ), + ) + } + } + } + } ?: emptyMap() + } + override fun setCompleted(taskId: Long, completed: Boolean) { val values = TaskWriteMapper.completionValues(completed, System.currentTimeMillis()) val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null) if (rows == 0) throw TaskWriteFailedException("complete task $taskId") } + /** + * Through the instances URI, which is what makes the provider fork an override + * rather than close the series. No instance row for the anchor means the task + * is not a series after all — the plain write is then the right one. + */ + override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) { + val instanceId = instanceIdFor(taskId, occurrenceStart) + ?: return setCompleted(taskId, completed) + val values = TaskWriteMapper.completionValues(completed, System.currentTimeMillis()) + val uri = TasksContract.instanceUri(authority(), instanceId) + val rows = resolver.update(uri, values.toContentValues(), null, null) + if (rows == 0) throw TaskWriteFailedException("complete instance $instanceId") + } + override fun deleteTask(taskId: Long) { resolver.delete(taskUri(authority(), taskId), null, null) } @@ -120,6 +240,31 @@ class AndroidTasksDataSource @Inject constructor( return result.lastPathSegment?.toLongOrNull() ?: throw TaskWriteFailedException("create local list: no id") } + override fun updateList(listId: Long, name: String, color: Int) { + val values = TaskWriteMapper.listValues(name, color) + val rows = resolver.update(listSyncUri(listId), values.toContentValues(), null, null) + if (rows == 0) throw TaskWriteFailedException("update list $listId") + } + + override fun deleteList(listId: Long) { + val rows = resolver.delete(listSyncUri(listId), null, null) + if (rows == 0) throw TaskWriteFailedException("delete list $listId") + } + + /** + * A list row addressed as its own account's sync adapter — the provider only + * lets that caller write the `tasklists` table, and the account has to be the + * row's own (the params are matched against it, not merely accepted). + */ + private fun listSyncUri(listId: Long): Uri { + val authority = authority() + val uri = TasksContract.listUri(authority, listId) + val account = resolver.query(uri, arrayOf(Lists.ACCOUNT_NAME, Lists.ACCOUNT_TYPE), null, null, null) + ?.use { c -> if (c.moveToFirst()) c.getString(0).orEmpty() to c.getString(1).orEmpty() else null } + ?: throw TaskWriteFailedException("list $listId not found") + return TasksContract.asSyncAdapter(uri, account.first, account.second) + } + // --- observation ---------------------------------------------------------- override fun registerObserver(onChange: () -> Unit): AutoCloseable { @@ -127,9 +272,16 @@ class AndroidTasksDataSource @Inject constructor( val observer = object : ContentObserver(Handler(Looper.getMainLooper())) { override fun onChange(selfChange: Boolean) = onChange() } - resolver.registerContentObserver(TasksContract.instancesUri(provider.authority), true, observer) - resolver.registerContentObserver(TasksContract.listsUri(provider.authority), true, observer) - return AutoCloseable { resolver.unregisterContentObserver(observer) } + // Register both or neither: if the second call throws, the first + // registration would otherwise leak (no AutoCloseable was handed back yet). + try { + resolver.registerContentObserver(TasksContract.instancesUri(provider.authority), true, observer) + resolver.registerContentObserver(TasksContract.listsUri(provider.authority), true, observer) + } catch (e: RuntimeException) { + runCatching { resolver.unregisterContentObserver(observer) } + throw e + } + return AutoCloseable { runCatching { resolver.unregisterContentObserver(observer) } } } private fun Map.toContentValues(): ContentValues { 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 new file mode 100644 index 0000000..ae5ef6b --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ModeRoutingTasksDataSource.kt @@ -0,0 +1,88 @@ +package de.jeanlucmakiola.agendula.data.tasks + +import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource +import de.jeanlucmakiola.agendula.domain.Task +import de.jeanlucmakiola.agendula.domain.TaskForm +import de.jeanlucmakiola.agendula.domain.TaskList +import de.jeanlucmakiola.agendula.domain.export.ExportTask +import javax.inject.Provider +import kotlin.time.Instant + +/** + * Routes every call to the store [StorageMode] selects. + * + * Per-call rather than bound once: the mode is a setting the user can change + * while the process lives, and [StorageModeHolder] pushes the new value into + * [ProviderResolver] without rebuilding the object graph. Both delegates are + * singletons, so this chooses between two existing objects. + * + * Room versus a third-party ContentProvider — nothing else. + */ +class ModeRoutingTasksDataSource( + private val resolver: ProviderResolver, + private val room: Provider, + private val external: Provider, +) : TasksDataSource { + + private fun active(): TasksDataSource = + when (resolver.mode()) { + StorageMode.OWN -> room.get() + StorageMode.EXTERNAL -> external.get() + } + + override fun taskLists(): List = active().taskLists() + override fun tasks(query: TaskQuery): List = active().tasks(query) + override fun task(taskId: Long): Task? = active().task(taskId) + override fun subtasks(parentTaskId: Long): List = active().subtasks(parentTaskId) + override fun insertTask(form: TaskForm): Long = active().insertTask(form) + override fun updateTask(taskId: Long, form: TaskForm) = active().updateTask(taskId, form) + + override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) = + active().updateInstance(taskId, occurrenceStart, form) + + override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) = active().setAlarm(taskId, minutesBeforeDue) + override fun alarms(): Map = active().alarms() + override fun exportTasks(listId: Long): List = active().exportTasks(listId) + override fun setCompleted(taskId: Long, completed: Boolean) = active().setCompleted(taskId, completed) + + override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) = + active().setCompletedInstance(taskId, occurrenceStart, completed) + + override fun deleteTask(taskId: Long) = active().deleteTask(taskId) + override fun createLocalList(name: String, color: Int): Long = active().createLocalList(name, color) + override fun updateList(listId: Long, name: String, color: Int) = active().updateList(listId, name, color) + override fun deleteList(listId: Long) = active().deleteList(listId) + /** + * Unlike every other method here, an observer is registered once and then + * *held* — so it cannot be routed per call, and would otherwise stay bound to + * whichever store was active when the flow started. Switching stores in + * Settings would then leave every open screen listening to the store it is no + * longer reading from. + * + * So the registration moves with the mode, and the switch itself counts as a + * change: the data underneath every live flow has just been replaced. + */ + override fun registerObserver(onChange: () -> Unit): AutoCloseable { + val lock = Any() + var closed = false + var handle: AutoCloseable? = runCatching { active().registerObserver(onChange) }.getOrNull() + + val modeHandle = resolver.onModeChanged { + synchronized(lock) { + if (!closed) { + handle?.let { runCatching { it.close() } } + handle = runCatching { active().registerObserver(onChange) }.getOrNull() + } + } + onChange() + } + return AutoCloseable { + synchronized(lock) { + closed = true + modeHandle.close() + handle?.let { runCatching { it.close() } } + handle = null + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderEnvironment.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderEnvironment.kt new file mode 100644 index 0000000..e60193f --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderEnvironment.kt @@ -0,0 +1,46 @@ +package de.jeanlucmakiola.agendula.data.tasks + +import android.content.Context +import android.content.pm.PackageManager +import androidx.core.content.ContextCompat +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The two platform facts [ProviderResolver] needs, behind an interface. + * + * Same seam the data source uses, for the same reason: which store a returning + * user lands on is decided by [ProviderResolver.autoMode], getting it wrong shows + * them an empty app, and that decision is worth testing on the JVM rather than + * only on a device. Everything Android-shaped lives here so the logic above stays + * plain Kotlin. + */ +interface ProviderEnvironment { + + /** The package declaring [authority], or `null` when nothing on the device does. */ + fun packageDeclaring(authority: String): String? + + /** Whether this app currently holds [permission]. */ + fun isGranted(permission: String): Boolean + + /** [packageName]'s own app name, or null when it cannot be read. */ + fun appLabel(packageName: String): String? +} + +@Singleton +class AndroidProviderEnvironment @Inject constructor( + @ApplicationContext private val context: Context, +) : ProviderEnvironment { + + override fun packageDeclaring(authority: String): String? = + context.packageManager.resolveContentProvider(authority, 0)?.packageName + + override fun isGranted(permission: String): Boolean = + ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + + override fun appLabel(packageName: String): String? = runCatching { + val pm = context.packageManager + pm.getApplicationLabel(pm.getApplicationInfo(packageName, 0)).toString() + }.getOrNull() +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderFlow.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderFlow.kt new file mode 100644 index 0000000..472df64 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderFlow.kt @@ -0,0 +1,31 @@ +package de.jeanlucmakiola.agendula.data.tasks + +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.retryWhen + +private const val BASE_RETRY_MS = 1_000L +private const val MAX_RETRY_MS = 30_000L + +/** 1s, 2s, 4s … capped at 30s, so a permanently-absent provider costs little. */ +private fun retryDelayMs(attempt: Long): Long = + (BASE_RETRY_MS shl attempt.coerceAtMost(5).toInt()).coerceAtMost(MAX_RETRY_MS) + +/** + * Recover a provider-backed flow without killing it. + * + * Provider reads fail for reasons that resolve on their own: the read permission + * isn't granted yet (first launch collects before the permission gate), or the + * provider app is mid-update. A terminal `catch` swallows the failure *and* + * cancels the upstream, so the flow never produces again — the screen stays empty + * until the process restarts, even after the user grants the permission. + * + * This emits [fallback] instead and keeps retrying with a capped backoff, so the + * collector recovers on its own once the provider becomes readable. + */ +fun Flow.recoveringFromProviderFailure(fallback: () -> T): Flow = + retryWhen { _, attempt -> + emit(fallback()) + delay(retryDelayMs(attempt)) + true + } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt index ba24eb0..7f752c2 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt @@ -1,15 +1,12 @@ package de.jeanlucmakiola.agendula.data.tasks -import android.content.Context -import android.content.pm.PackageManager -import androidx.core.content.ContextCompat -import dagger.hilt.android.qualifiers.ApplicationContext +import java.util.concurrent.CopyOnWriteArrayList import javax.inject.Inject import javax.inject.Singleton /** - * A tasks provider Agendula can talk to. The same dmfs `TaskProvider` backs every - * candidate, so the [TasksContract] columns apply regardless of which is present. + * An external tasks provider Agendula can talk to. Every candidate runs the same + * dmfs `TaskProvider`, so the [TasksContract] columns apply to either. */ data class TaskProvider( val authority: String, @@ -19,41 +16,122 @@ data class TaskProvider( ) /** - * The A/B seam. Detects which tasks provider is installed at runtime and which - * permission set it needs, so nothing above the data layer hardcodes an - * authority. Under Posture B (bundled provider) this simply finds our own - * `org.dmfs.tasks` first. See docs/PLAN.md. + * Discovers the *external* tasks providers (OpenTasks, tasks.org) that + * [StorageMode.EXTERNAL] can be pointed at. + * + * This used to be the A/B seam between an external provider and one Agendula + * bundled itself. That second half is gone: [StorageMode.OWN] is a Room database + * with no authority, no ContentResolver and nothing to permit, so there is + * nothing here for it to resolve. See `docs/OWN-STORE.md`. + * + * Which store is active comes from [storageMode]; how that gets decided when the + * user has not chosen is [autoMode]. */ @Singleton class ProviderResolver @Inject constructor( - @ApplicationContext private val context: Context, + private val environment: ProviderEnvironment, ) { - /** The active provider, or `null` when no tasks provider is installed. */ - fun resolve(): TaskProvider? { - for (candidate in CANDIDATES) { - val info = context.packageManager.resolveContentProvider(candidate.authority, 0) - ?: continue - return candidate.copy(packageName = info.packageName) + private val modeListeners = CopyOnWriteArrayList<() -> Unit>() + + /** + * The user's explicit choice, or `null` while they have not made one (which is + * the normal state — most people never open Settings). Kept as a plain field + * rather than read from DataStore on demand because [resolve] is called from + * synchronous data-source code on every query, including from the main thread + * via `providerStatus()`. [StorageModeHolder] owns keeping it current. + * + * Assigning a *different* mode notifies [onModeChanged]: every live store + * observer is bound to one store and has to be moved across. + */ + @Volatile + var storageMode: StorageMode? = null + set(value) { + val changed = field != value + field = value + if (changed) modeListeners.forEach { it() } + } + + /** + * Observe switches between stores. Fires on the thread that set [storageMode] + * — [StorageModeHolder]'s collector — so listeners must be cheap and must not + * block. + */ + fun onModeChanged(listener: () -> Unit): AutoCloseable { + modeListeners += listener + return AutoCloseable { modeListeners -= listener } + } + + /** The active store, resolving the undecided case through [autoMode]. */ + fun mode(): StorageMode = storageMode ?: autoMode() + + /** + * The provider to query, or `null` — either because [StorageMode.OWN] is + * active and there is no provider involved at all, or because + * [StorageMode.EXTERNAL] is and none is installed. Callers that need to tell + * those apart ask [mode]. + */ + fun resolve(): TaskProvider? = when (mode()) { + StorageMode.OWN -> null + StorageMode.EXTERNAL -> resolveExternal() + } + + /** + * What to use when the user has not chosen — and the one piece of real + * judgement in this class, because getting it wrong loses people their data. + * + * Ranking our own store first unconditionally would be wrong: someone who + * has been using Agendula over OpenTasks since 0.3.x would update, land on an + * empty database, and reasonably conclude their tasks were deleted. + * + * So the tell is **whether we already hold an external provider's runtime + * permission**. That is a dangerous permission — it can only be there because + * a previous version asked and the user agreed, which is precisely the + * definition of "this person is an existing Posture A user". A fresh install + * never holds it, and gets our own store. + * + * Deliberately cheap and synchronous: a PackageManager lookup and a permission + * check, no database probe. Settings overrides it either way. + */ + fun autoMode(): StorageMode { + val external = resolveExternal() + return if (external != null && hasPermission(external)) StorageMode.EXTERNAL else StorageMode.OWN + } + + /** The first installed external candidate, or `null` when none is present. */ + fun resolveExternal(): TaskProvider? { + for (candidate in EXTERNAL_CANDIDATES) { + val packageName = environment.packageDeclaring(candidate.authority) ?: continue + return candidate.copy(packageName = packageName) } return null } fun hasPermission(provider: TaskProvider): Boolean = - granted(provider.readPermission) && granted(provider.writePermission) + environment.isGranted(provider.readPermission) && environment.isGranted(provider.writePermission) - private fun granted(permission: String): Boolean = - ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + /** + * Whether the active store can be read at all. + * + * [StorageMode.OWN] always can — it is our own database, with nothing to + * install and nothing to grant. Only [StorageMode.EXTERNAL] can be + * unreadable. Callers that gate on `resolve() != null` instead get this wrong + * the moment OWN is active, because OWN resolves to no provider by design. + */ + fun canReadStore(): Boolean = when (mode()) { + StorageMode.OWN -> true + StorageMode.EXTERNAL -> resolveExternal()?.let(::hasPermission) == true + } companion object { /** * Verified on-device: tasks.org exposes `org.tasks.opentasks` backed by - * `org.dmfs.provider.tasks.TaskProvider`, guarded by - * `org.tasks.permission.*` (dangerous). OpenTasks uses `org.dmfs.tasks` - * + `org.dmfs.permission.*`. OpenTasks is listed first as the canonical - * authority; on a device with only one installed, order is moot. + * `org.dmfs.provider.tasks.TaskProvider`, guarded by `org.tasks.permission.*` + * (dangerous). OpenTasks uses `org.dmfs.tasks` + `org.dmfs.permission.*`. + * OpenTasks is listed first as the canonical authority; on a device with + * only one installed, order is moot. */ - val CANDIDATES: List = listOf( + val EXTERNAL_CANDIDATES: List = listOf( TaskProvider( authority = "org.dmfs.tasks", readPermission = "org.dmfs.permission.READ_TASKS", diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StartupGate.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StartupGate.kt new file mode 100644 index 0000000..fe160f6 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StartupGate.kt @@ -0,0 +1,46 @@ +package de.jeanlucmakiola.agendula.data.tasks + +import de.jeanlucmakiola.agendula.data.di.ApplicationScope +import de.jeanlucmakiola.agendula.data.tasks.legacy.OneShotImport +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The work that has to finish before anything reads a task store: the stored + * [StorageMode] has to reach [ProviderResolver], and a v0.3.x install's tasks + * have to be imported out of the dmfs provider's file into Room. + * + * Both are startup races with the same shape. Reading before the mode lands + * answers from `autoMode()` instead of the user's choice; reading before the + * import lands shows an upgrading user an empty app, which is the single worst + * thing this migration could do. + */ +@Singleton +class StartupGate @Inject constructor( + private val storageModeHolder: StorageModeHolder, + private val oneShotImport: OneShotImport, + @ApplicationScope private val scope: CoroutineScope, +) { + + private val ready = CompletableDeferred() + + /** Call once, from `Application.onCreate`. */ + fun start() { + storageModeHolder.start() + scope.launch { + // Opens the gate even on failure: a store that cannot be imported is + // still better shown empty than not shown at all, and the source file + // is left where it was either way. + runCatching { + storageModeHolder.awaitReady() + oneShotImport.runIfNeeded() + } + ready.complete(Unit) + } + } + + suspend fun awaitReady() = ready.await() +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StorageMode.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StorageMode.kt new file mode 100644 index 0000000..6ca9963 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StorageMode.kt @@ -0,0 +1,26 @@ +package de.jeanlucmakiola.agendula.data.tasks + +/** + * Which task store backs the app — the user's choice, per `docs/OWN-STORE.md`. + * + * Only two values, though `docs/STORAGE-AND-SYNC.md` describes three modes. + * **Synced is not a third store**: it is [OWN] with an account attached to a + * list, which is derived state rather than something the user picks. Attaching + * one is a plain `UPDATE task_lists SET account_id = ?` — not the full data + * migration it was under the dmfs provider, whose `ACCOUNT_TYPE` was write-once. + */ +enum class StorageMode { + /** + * Agendula's own Room database. The default, and always available: there is + * no authority, no ContentResolver and no permission to grant. + */ + OWN, + + /** + * A tasks provider app already on the device (OpenTasks, tasks.org), synced by + * whatever that provider's engine is — DAVx5 and friends. Still fully + * supported, now a choice rather than the only way. Requires that provider's + * runtime read/write permissions. + */ + EXTERNAL, +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StorageModeHolder.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StorageModeHolder.kt new file mode 100644 index 0000000..81b86dc --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StorageModeHolder.kt @@ -0,0 +1,47 @@ +package de.jeanlucmakiola.agendula.data.tasks + +import de.jeanlucmakiola.agendula.data.di.ApplicationScope +import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Mirrors the stored [StorageMode] into [ProviderResolver]. + * + * The resolver is consulted synchronously from every data-source call and from + * `providerStatus()` on the main thread, so it cannot read DataStore itself. + * This is the one component that bridges the two: it collects the preference for + * the life of the process and pushes each value across. + * + * [awaitReady] exists for the startup race. Until the first DataStore emission + * arrives the resolver's mode is `null` and `ProviderResolver.autoMode` answers + * instead — fine as a steady state, wrong for a user who explicitly chose the + * other mode. Anything that touches the provider before the UI is up (the launch + * reminder re-sync, notably) should wait rather than risk reading the wrong + * store and rescheduling every alarm off it. + */ +@Singleton +class StorageModeHolder @Inject constructor( + private val prefs: SettingsPrefs, + private val resolver: ProviderResolver, + @ApplicationScope private val scope: CoroutineScope, +) { + + private val firstValue = CompletableDeferred() + + /** Starts mirroring. Idempotent in effect; call once, from `Application.onCreate`. */ + fun start() { + scope.launch { + prefs.storageMode.collect { mode -> + resolver.storageMode = mode + firstValue.complete(Unit) + } + } + } + + /** Suspends until the stored mode has been applied at least once. */ + suspend fun awaitReady() = firstValue.await() +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapper.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapper.kt index c8b36ab..ce856b1 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapper.kt @@ -5,6 +5,7 @@ import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskList +import de.jeanlucmakiola.agendula.domain.export.ExportTask import de.jeanlucmakiola.agendula.domain.priorityFromICal import de.jeanlucmakiola.agendula.domain.statusFromInt import kotlin.time.Instant @@ -16,10 +17,17 @@ object TaskMapper { fun instant(name: String): Instant? = r.getLong(name)?.let { Instant.fromEpochMilliseconds(it) } - val instanceId = r.getLong(Tasks.ID) ?: 0L + val rowId = r.getLong(Tasks.ID) ?: 0L + // Derived from the rule columns rather than the `is_recurring` column + // alone: that column only exists from OpenTasks 1.4.0 (DB 23) and is + // absent on tasks.org's bundled provider (DB 22), where reading it + // would silently report every recurring task as one-off — and route + // its edits onto the series anchor. + val recurring = r.getString(Tasks.RRULE) != null || + r.getString(Tasks.RDATE) != null || + r.getBoolean(Instances.IS_RECURRING) return Task( - id = instanceId, - taskId = r.getLong(Instances.TASK_ID) ?: instanceId, + taskId = r.getLong(Instances.TASK_ID) ?: rowId, listId = r.getLong(Tasks.LIST_ID) ?: 0L, title = r.getString(Tasks.TITLE).orEmpty(), description = r.getString(Tasks.DESCRIPTION), @@ -38,13 +46,66 @@ object TaskMapper { listName = r.getString(Tasks.LIST_NAME), accountName = r.getString(Tasks.ACCOUNT_NAME), parentId = r.getLong(Tasks.PARENT_ID), - isRecurring = r.getBoolean(Instances.IS_RECURRING), + isRecurring = recurring, + occurrenceStart = if (recurring) occurrenceAnchor(r) else null, distanceFromCurrent = r.getInt(Instances.DISTANCE_FROM_CURRENT), created = instant(Tasks.CREATED), lastModified = instant(Tasks.LAST_MODIFIED), ) } + /** + * The occurrence's `RECURRENCE-ID` anchor. + * + * `instance_original_time` is the provider's own name for it and is set on + * every occurrence of a recurring task, so it is read first. It is absent on + * older provider schemas, where the fallbacks reconstruct the same value: a + * DTSTART-anchored series instantiates each occurrence at its start, and a + * series carrying only DUE anchors on the due date instead. + */ + fun occurrenceAnchor(r: ColumnReader): Instant? = + ( + r.getLong(Instances.INSTANCE_ORIGINAL_TIME) + ?: r.getLong(Instances.INSTANCE_START) + ?: r.getLong(Instances.INSTANCE_DUE) + )?.let { Instant.fromEpochMilliseconds(it) } + + /** + * Maps a row of the **`tasks` table** — a master task, not an occurrence. + * + * Export reads there rather than from `instances` on purpose: in the instances + * view a recurring task appears once per occurrence with its times already + * resolved and no rule attached, so exporting from it would write the same + * task many times over and drop the RRULE that produced them. Here each task + * appears exactly once, carrying the rule itself. + */ + fun exportTask(r: ColumnReader): ExportTask { + fun instant(name: String): Instant? = + r.getLong(name)?.let { Instant.fromEpochMilliseconds(it) } + + return ExportTask( + taskId = r.getLong(Tasks.ID) ?: 0L, + uid = r.getString(Tasks.UID), + title = r.getString(Tasks.TITLE).orEmpty(), + description = r.getString(Tasks.DESCRIPTION), + location = r.getString(Tasks.LOCATION), + url = r.getString(Tasks.URL), + priority = priorityFromICal(r.getInt(Tasks.PRIORITY)), + status = statusFromInt(r.getInt(Tasks.STATUS)), + percentComplete = r.getInt(Tasks.PERCENT_COMPLETE), + // The task's own columns, not the instance view's resolved ones. + start = instant(Tasks.DTSTART), + due = instant(Tasks.DUE), + isAllDay = r.getBoolean(Tasks.IS_ALLDAY), + completedAt = instant(Tasks.COMPLETED), + created = instant(Tasks.CREATED), + lastModified = instant(Tasks.LAST_MODIFIED), + rrule = r.getString(Tasks.RRULE), + rdate = r.getString(Tasks.RDATE), + parentId = r.getLong(Tasks.PARENT_ID)?.takeIf { it > 0 }, + ) + } + fun taskList(r: ColumnReader): TaskList = TaskList( id = r.getLong(Lists.ID) ?: 0L, name = r.getString(Lists.NAME).orEmpty(), diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskProjections.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskProjections.kt index 6b6fa45..b7464bb 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskProjections.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskProjections.kt @@ -1,8 +1,6 @@ package de.jeanlucmakiola.agendula.data.tasks -import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists -import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks /** Column lists requested from the provider. Order is irrelevant; we read by name. */ object TaskProjections { @@ -18,31 +16,9 @@ object TaskProjections { Lists.ACCOUNT_TYPE, ) - /** Read from the `instances` view (inherits all task columns). */ - val INSTANCES: Array = arrayOf( - Tasks.ID, - Instances.TASK_ID, - Tasks.LIST_ID, - Tasks.TITLE, - Tasks.DESCRIPTION, - Tasks.LOCATION, - Tasks.URL, - Tasks.PRIORITY, - Tasks.STATUS, - Tasks.PERCENT_COMPLETE, - Tasks.COMPLETED, - Tasks.IS_ALLDAY, - Tasks.TZ, - Instances.INSTANCE_START, - Instances.INSTANCE_DUE, - Tasks.TASK_COLOR, - Tasks.LIST_COLOR, - Tasks.LIST_NAME, - Tasks.ACCOUNT_NAME, - Tasks.PARENT_ID, - Instances.IS_RECURRING, - Instances.DISTANCE_FROM_CURRENT, - Tasks.CREATED, - Tasks.LAST_MODIFIED, - ) + // No `instances` projection on purpose: that read passes `projection = null` + // (all columns), because the view's shape differs across provider versions — + // tasks.org's bundled OpenTasks has no `is_recurring`, for one. A fixed list + // here would drift out of sync with the by-name mapper and quietly drop + // columns it depends on. See AndroidTasksDataSource.queryInstances. } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapper.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapper.kt index 601ac24..f54dddd 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapper.kt @@ -1,9 +1,21 @@ package de.jeanlucmakiola.agendula.data.tasks +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Alarm 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.domain.TaskForm import de.jeanlucmakiola.agendula.domain.toICal +import kotlin.time.Instant + +private const val MILLIS_PER_DAY = 24L * 60 * 60 * 1000 + +/** Floor to UTC midnight when [allDay], else pass through unchanged. */ +private fun Instant.forAllDay(allDay: Boolean): Instant = + if (!allDay) this + else Instant.fromEpochMilliseconds( + Math.floorDiv(toEpochMilliseconds(), MILLIS_PER_DAY) * MILLIS_PER_DAY, + ) /** * Turns a [TaskForm] / mutation into a name→value map. Pure (no ContentValues), @@ -39,8 +51,17 @@ object TaskWriteMapper { } } put(Tasks.IS_ALLDAY, if (form.isAllDay) 1 else 0) - put(Tasks.DTSTART, form.start?.toEpochMilliseconds()) - put(Tasks.DUE, form.due?.toEpochMilliseconds()) + // All-day tasks are date-only in iCalendar. The provider reads them back + // through DateTime.toAllDay(), which drops the time-of-day and resolves the + // remaining date against UTC — so a local-midnight instant lands on the + // previous day for anyone west of UTC. Pin all-day values to UTC midnight. + put(Tasks.DTSTART, form.start?.forAllDay(form.isAllDay)?.toEpochMilliseconds()) + put(Tasks.DUE, form.due?.forAllDay(form.isAllDay)?.toEpochMilliseconds()) + // DUE and DURATION are mutually exclusive. The provider's Validating + // processor evaluates the *merged* row (supplied values over the stored + // ones), so writing DUE onto a task that already carries a DURATION throws + // "Only one of DUE or DURATION must be supplied." Clear it alongside. + put(Tasks.DURATION, null) put(Tasks.PARENT_ID, form.parentId) // The provider treats a null tz as local time; set it explicitly for // timed tasks so the stored instant is unambiguous across zones. @@ -48,6 +69,16 @@ object TaskWriteMapper { put(Tasks.TZ, if (timed) tzId else null) } + /** + * Values for an update through the *instances* URI (a recurring occurrence). + * The provider clones the row into an override and strips list/recurrence + * fields as it goes, so LIST_ID and PARENT_ID are dropped here rather than + * written and silently ignored — moving one occurrence between lists or + * parents isn't a thing the override model expresses. + */ + fun instanceValues(form: TaskForm, tzId: String): Map = + taskValues(form, tzId) - Tasks.LIST_ID - Tasks.PARENT_ID + fun completionValues(completed: Boolean, nowMillis: Long): Map = if (completed) { mapOf( @@ -63,9 +94,26 @@ object TaskWriteMapper { ) } - fun localListValues(name: String, color: Int): Map = mapOf( + /** + * A reminder for [taskId], as an Alarm property row. The provider's validator + * requires MINUTES_BEFORE, REFERENCE (non-negative) and ALARM_TYPE on every + * write, so all three are always present. + */ + fun alarmValues(taskId: Long, minutesBeforeDue: Int): Map = mapOf( + Properties.TASK_ID to taskId, + Properties.MIMETYPE to Alarm.MIMETYPE, + Alarm.MINUTES_BEFORE to minutesBeforeDue, + Alarm.REFERENCE to Alarm.REFERENCE_DUE, + Alarm.ALARM_TYPE to Alarm.TYPE_MESSAGE, + ) + + /** The user-owned columns of a list — what an edit is allowed to change. */ + fun listValues(name: String, color: Int): Map = mapOf( Lists.NAME to name.trim(), Lists.COLOR to color, + ) + + fun localListValues(name: String, color: Int): Map = listValues(name, color) + mapOf( Lists.ACCOUNT_NAME to TasksContract.LOCAL_ACCOUNT_NAME, Lists.ACCOUNT_TYPE to TasksContract.LOCAL_ACCOUNT_TYPE, Lists.VISIBLE to 1, 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 a400d59..63e1ce8 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 @@ -66,6 +66,9 @@ object TasksContract { const val IS_ALLDAY = "is_allday" const val TZ = "tz" const val RRULE = "rrule" + const val RDATE = "rdate" + /** Set on an override row — the master occurrence this one replaces. */ + const val ORIGINAL_INSTANCE_ID = "original_instance_id" const val PARENT_ID = "parent_id" const val SORTING = "sorting" const val CREATED = "created" @@ -96,8 +99,59 @@ object TasksContract { const val INSTANCE_DUE_SORTING = "instance_due_sorting" const val DISTANCE_FROM_CURRENT = "distance_from_current" const val IS_RECURRING = "is_recurring" + + /** + * The occurrence's `RECURRENCE-ID` — the time this occurrence was + * instantiated at, before any override moved it. Set on every occurrence + * of a recurring task, which is what makes it the occurrence's identity. + */ + const val INSTANCE_ORIGINAL_TIME = "instance_original_time" } + /** The `properties` table — per-task side rows, discriminated by [Properties.MIMETYPE]. */ + object Properties { + const val PATH = "properties" + const val PROPERTY_ID = "property_id" + const val TASK_ID = "task_id" + const val MIMETYPE = "mimetype" + } + + /** + * An alarm property row — a per-task reminder lead. + * + * Storage and sync format *only*: the provider fires nothing (its alarm + * scheduling is commented out and the internal `alarms` table is never + * populated), so [de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler] + * still arms the real AlarmManager alarm. Writing it here is what makes the + * lead survive a sync and show up in other OpenTasks clients. + * + * The columns are the generic `dataN` slots; the meanings below are the + * Alarm property's contract for them. + */ + object Alarm { + const val MIMETYPE = "vnd.android.cursor.item/alarm" + + /** `data0` — minutes from the reference date; positive means *before* it. */ + const val MINUTES_BEFORE = "data0" + + /** `data1` — which date to count from. */ + const val REFERENCE = "data1" + + /** `data2` — optional message shown with the alarm. */ + const val MESSAGE = "data2" + + /** `data3` — alarm kind. Must be present, and non-zero to count as an alarm. */ + const val ALARM_TYPE = "data3" + + const val REFERENCE_DUE = 1 + const val REFERENCE_START = 2 + + /** 0 (NOTHING) is excluded from the provider's `has_alarms` count — use MESSAGE. */ + const val TYPE_MESSAGE = 1 + } + + fun propertiesUri(authority: String): Uri = Uri.parse("content://$authority/${Properties.PATH}") + // --- status values (TaskColumns.STATUS_*) -------------------------------- const val STATUS_NEEDS_ACTION = 0 const val STATUS_IN_PROCESS = 1 @@ -109,9 +163,20 @@ object TasksContract { fun authorityUri(authority: String): Uri = Uri.parse("content://$authority") fun listsUri(authority: String): Uri = Uri.parse("content://$authority/${Lists.PATH}") + fun listUri(authority: String, listId: Long): Uri = + Uri.parse("content://$authority/${Lists.PATH}/$listId") fun tasksUri(authority: String): Uri = Uri.parse("content://$authority/${Tasks.PATH}") fun instancesUri(authority: String): Uri = Uri.parse("content://$authority/${Instances.PATH}") + /** + * A single occurrence. Updating through this URI is how a *recurring* task is + * edited: the provider clones the row into an override task + * (`original_instance_id` set, recurrence fields stripped) instead of moving + * the series anchor, which is what writing to `tasks/` would do. + */ + fun instanceUri(authority: String, instanceId: Long): Uri = + Uri.parse("content://$authority/${Instances.PATH}/$instanceId") + /** Append the sync-adapter params required to write local-account rows. */ fun asSyncAdapter(uri: Uri, accountName: String, accountType: String): Uri = uri.buildUpon() 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 e73d7ee..49b50bc 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,6 +3,17 @@ 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 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) /** What to fetch from the provider. Smart-list date logic is applied above this. */ data class TaskQuery( @@ -23,10 +34,67 @@ interface TasksDataSource { fun insertTask(form: TaskForm): Long fun updateTask(taskId: Long, form: TaskForm) + + /** + * Update a single occurrence of a recurring task, addressed by the task row and + * the occurrence's `RECURRENCE-ID` anchor ([Task.occurrenceStart]). The store + * forks an override rather than moving the series anchor — which is what + * [updateTask] would do, since a recurring task's start/due are the + * occurrence's resolved times. + * + * Addressing by `(taskId, occurrenceStart)` rather than by a materialised + * 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) + /** + * Set (or clear, with `null`) the per-task reminder lead, stored as an Alarm + * property row. 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?) + + /** Every task's reminder, by task id. One query, for the scheduler. */ + fun alarms(): Map + + /** + * Every task in [listId] read from the **`tasks` table**, for export. Masters, + * not occurrences — see [TaskMapper.exportTask] for why that distinction + * matters. Excludes rows the provider has flagged deleted-but-unsynced. + */ + fun exportTasks(listId: Long): List + fun setCompleted(taskId: Long, completed: Boolean) + + /** + * Complete (or reopen) **one occurrence** of a recurring task, addressed the + * same way [updateInstance] is. Ticking a series through [setCompleted] would + * close the master row, which takes every past and future occurrence out of + * every list at once. + * + * Implementations fall back to [setCompleted] when the row turns out not to + * be a series master — an override, or a plain task the caller happened to + * hand an anchor for — so the routing above cannot get this wrong. + */ + fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) fun deleteTask(taskId: Long) fun createLocalList(name: String, color: Int): Long + /** Rename and recolour [listId]. */ + fun updateList(listId: Long, name: String, color: Int) + + /** + * Delete [listId] **and the tasks in it** — `tasks.list_id` cascades on the + * Room path, and the provider does the same on the External one. + * + * Only ever called for a local, device-only list: a collection that belongs + * to an account is the server's to remove, and neither store expresses a + * collection tombstone yet. The UI gates on [TaskList.isLocal]; this seam + * does not re-check it. + */ + fun deleteList(listId: Long) + /** Observe any change to tasks/lists; [onChange] fires on a background thread. */ fun registerObserver(onChange: () -> Unit): AutoCloseable } 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 10efd6f..4601732 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 @@ -37,9 +37,25 @@ interface TasksRepository { * since the form loaded. Pass `null` to force the write (overwrite-anyway). */ suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant? = null) - suspend fun setCompleted(taskId: Long, completed: Boolean) + /** + * Complete or reopen a task. Pass the occurrence's [Task.occurrenceStart] so a + * recurring series forks a `RECURRENCE-ID` override for that one occurrence + * instead of closing the whole series; `null` completes the row itself. + */ + suspend fun setCompleted(taskId: Long, occurrenceStart: Instant?, completed: Boolean) suspend fun deleteTask(taskId: Long) + + /** + * 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? suspend fun createLocalList(name: String, color: Int): Long + suspend fun updateList(listId: Long, name: String, color: Int) + + /** Deletes the list **and its tasks**. Local lists only — see [TasksDataSource.deleteList]. */ + suspend fun deleteList(listId: Long) /** Synchronous snapshot for the permission/onboarding gate. */ fun providerStatus(): ProviderStatus 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 d137d1e..ab2ef75 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 @@ -28,6 +28,7 @@ import kotlin.time.Instant class TasksRepositoryImpl @Inject constructor( private val dataSource: TasksDataSource, private val providerResolver: ProviderResolver, + private val startupGate: StartupGate, @IoDispatcher private val io: CoroutineDispatcher, ) : TasksRepository { @@ -80,22 +81,50 @@ class TasksRepositoryImpl @Inject constructor( } override suspend fun createTask(form: TaskForm): Long = - withContext(io) { dataSource.insertTask(form) } + withContext(io) { + val id = dataSource.insertTask(form) + form.reminderMinutesBeforeDue?.let { dataSource.setAlarm(id, it) } + id + } + + override suspend fun reminderFor(taskId: Long): Int? = + withContext(io) { runCatching { dataSource.alarms()[taskId]?.minutesBefore }.getOrNull() } override suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant?) = withContext(io) { - // Conflict-safe overwrite: re-read just before writing and bail if the - // provider's last_modified moved since the form captured it (external - // sync / another app). A null baseline means "force / overwrite anyway". + // Re-read just before writing: it settles the conflict check *and* tells + // us which URI to write through. + val current = dataSource.task(taskId) + // Conflict-safe overwrite: bail if the provider's last_modified moved + // since the form captured it (external sync / another app). A null + // baseline means "force / overwrite anyway". if (expectedLastModified != null) { - val current = dataSource.task(taskId)?.lastModified - if (current != null && current != expectedLastModified) throw TaskConflictException(taskId) + val seen = current?.lastModified + if (seen != null && seen != expectedLastModified) throw TaskConflictException(taskId) + } + // Write the reminder first: forking a recurring occurrence copies the + // task's properties onto the new override row, so setting the alarm + // beforehand is what carries it across. + dataSource.setAlarm(taskId, form.reminderMinutesBeforeDue) + // A recurring task's start/due are one occurrence's resolved times, so + // writing them back to the task row would re-anchor the whole series. + // Going through the occurrence forks an override instead. + val occurrence = current?.takeIf { it.isRecurring }?.occurrenceStart + if (occurrence != null) { + dataSource.updateInstance(taskId, occurrence, form) + } else { + dataSource.updateTask(taskId, form) } - dataSource.updateTask(taskId, form) } - override suspend fun setCompleted(taskId: Long, completed: Boolean) = - withContext(io) { dataSource.setCompleted(taskId, completed) } + override suspend fun setCompleted(taskId: Long, occurrenceStart: Instant?, completed: Boolean) = + withContext(io) { + if (occurrenceStart != null) { + dataSource.setCompletedInstance(taskId, occurrenceStart, completed) + } else { + dataSource.setCompleted(taskId, completed) + } + } override suspend fun deleteTask(taskId: Long) = withContext(io) { dataSource.deleteTask(taskId) } @@ -103,18 +132,32 @@ class TasksRepositoryImpl @Inject constructor( override suspend fun createLocalList(name: String, color: Int): Long = withContext(io) { dataSource.createLocalList(name, color) } + override suspend fun updateList(listId: Long, name: String, color: Int) = + withContext(io) { dataSource.updateList(listId, name, color) } + + override suspend fun deleteList(listId: Long) = + withContext(io) { dataSource.deleteList(listId) } + override fun providerStatus(): ProviderStatus { + // Our own store is always ready: it ships with the app, needs no provider + // and no grant. The permission gate only ever applied to External mode — + // now that is visibly true rather than a special case inside it. + if (providerResolver.mode() == StorageMode.OWN) return ProviderStatus.READY val provider = providerResolver.resolve() ?: return ProviderStatus.NO_PROVIDER return if (providerResolver.hasPermission(provider)) ProviderStatus.READY else ProviderStatus.NEEDS_PERMISSION } /** - * Emits an initial load, then re-loads on every provider change. The observer + * Emits an initial load, then re-loads on every store change. The observer * callback (main thread) only pokes a conflated channel; the actual blocking * query runs on [io]. */ private fun observing(load: () -> T): Flow = callbackFlow { + // Nothing reads a store before the stored mode has landed and a v0.3.x + // install has been imported — otherwise the first emission comes from the + // wrong store, or from an empty one. + startupGate.awaitReady() val ticks = Channel(Channel.CONFLATED) val handle = dataSource.registerObserver { ticks.trySend(Unit) } ticks.trySend(Unit) // prime the initial emission diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImport.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImport.kt new file mode 100644 index 0000000..0bf99ee --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImport.kt @@ -0,0 +1,384 @@ +package de.jeanlucmakiola.agendula.data.tasks.legacy + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import dagger.hilt.android.qualifiers.ApplicationContext +import de.jeanlucmakiola.agendula.data.tasks.CursorColumnReader +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.TasksDatabase +import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE +import de.jeanlucmakiola.agendula.domain.statusFromInt +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import java.io.File +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Instant + +/** How much one import moved. */ +data class ImportCounts(val lists: Int, val tasks: Int, val alarms: Int) + +/** The outcome of [OneShotImport.runIfNeeded] or [OneShotImport.reimportFromArchive]. */ +sealed interface ImportResult { + /** The DataStore flag was already set; nothing was read. */ + data object AlreadyDone : ImportResult + + /** No legacy database on disk — a fresh install, or one already archived. */ + data object NothingToImport : ImportResult + + data class Imported(val counts: ImportCounts) : ImportResult + + /** Nothing landed: the transaction rolled back and the source is untouched. */ + data class Failed(val cause: Throwable) : ImportResult +} + +/** + * Moves a v0.3.x install's tasks out of the bundled dmfs provider's SQLite file + * and into Room, once (`docs/OWN-STORE.md`, "Migrating existing users"). + * + * The file is opened read-only and directly — no provider, no ContentResolver — + * so this keeps working after `:provider` is deleted. Everything lands in one + * Room transaction with verified counts, so a failure leaves Room exactly as it + * was and the source file exactly where it was. + * + * dmfs accounts are not carried over: every list is imported as a device-only + * list (`account_id IS NULL`), including one that sat under a real account — + * only reachable if the user had pointed DAVx5 at our authority. Their task + * `uid`s are preserved, which is what lets those rows be re-attached to an + * account once sync lands. + */ +@Singleton +class OneShotImport @Inject constructor( + @ApplicationContext private val context: Context, + private val database: TasksDatabase, + private val dataStore: DataStore, +) { + + /** Whether the import has run. Set before the rename, so both guards hold. */ + val isDone: Flow = dataStore.data.map { it[IMPORT_DONE] ?: false } + + /** + * Steps 1–8 of the plan: archive `databases/tasks.db`, import it, record + * completion. Safe to call on every launch. + * + * The archive happens *before* the import, and the import always replaces, so + * that every point this can be killed at re-enters correctly: + * + * - killed after the rename, before the import — the next run finds the + * archive, imports it, and nothing is lost; + * - killed after the import commits, before the flag is written — the next + * run truncates and re-imports the same archive, so the result is the same + * rather than doubled. + * + * Renaming last would leave that second window open: the flag would be unset + * and `tasks.db` still in place, and the next launch would import it a second + * time on top of the first. That is the window the plan's "guarded by a + * DataStore flag *and* by the rename" is meant to close, and only this order + * actually closes it. + */ + suspend fun runIfNeeded(): ImportResult = withContext(Dispatchers.IO) { + if (isDone.first()) return@withContext ImportResult.AlreadyDone + val source = archivedSource() ?: run { + markDone() + return@withContext ImportResult.NothingToImport + } + val counts = runCatching { importFrom(source, replaceExisting = true) } + .getOrElse { return@withContext ImportResult.Failed(it) } + markDone() + ImportResult.Imported(counts) + } + + /** + * The rollback path: re-run against the archived `tasks.db.imported`, + * truncating the Room tables first so a second attempt replaces rather than + * merges. Reached by a targeted fix release, not by the app on its own. + */ + suspend fun reimportFromArchive(): ImportResult = withContext(Dispatchers.IO) { + val source = archivedSource() ?: return@withContext ImportResult.NothingToImport + val counts = runCatching { importFrom(source, replaceExisting = true) } + .getOrElse { return@withContext ImportResult.Failed(it) } + markDone() + ImportResult.Imported(counts) + } + + /** + * The legacy database as `tasks.db.imported`, archiving it first if it is + * still under its live name. `null` when there is nothing to import. + */ + private fun archivedSource(): File? { + val archive = context.getDatabasePath(ARCHIVE_NAME) + if (archive.exists()) return archive + val live = context.getDatabasePath(LEGACY_NAME) + if (!live.exists()) return null + return if (archive(live)) archive else live + } + + /** Clears the completion flag so [runIfNeeded] will import again. */ + suspend fun clearCompletion() { + dataStore.edit { it.remove(IMPORT_DONE) } + } + + /** + * Steps 2–6 against an arbitrary dmfs database: read it read-only, then write + * everything in one Room transaction whose counts are verified before it + * commits. Blocking — call it off the main thread. + */ + fun importFrom(source: File, replaceExisting: Boolean = false): ImportCounts { + val snapshot = SQLiteDatabase.openDatabase(source.path, null, SQLiteDatabase.OPEN_READONLY) + .use(::read) + return database.runInTransaction { + if (replaceExisting) truncate() + val baseline = tableCounts() + val written = write(snapshot) + verify(written, baseline) + written + } + } + + // --- reading the dmfs file ------------------------------------------------ + + private fun read(db: SQLiteDatabase): LegacySnapshot { + val lists = mutableListOf() + db.rawQuery("SELECT * FROM Lists ORDER BY _id", null).use { cursor -> + val r = CursorColumnReader(cursor) + while (cursor.moveToNext()) { + val id = r.getLong("_id") ?: continue + lists += LegacyList( + id = id, + entity = TaskListEntity( + name = r.getString("list_name").orEmpty(), + color = r.getInt("list_color") ?: 0, + accountId = null, + isVisible = r.getBoolean("visible"), + isSynced = r.getBoolean("sync_enabled"), + owner = r.getString("list_owner"), + ), + ) + } + } + + val rows = mutableListOf() + db.rawQuery("SELECT * FROM Tasks WHERE _deleted IS NULL OR _deleted = 0 ORDER BY _id", null) + .use { cursor -> + val r = CursorColumnReader(cursor) + while (cursor.moveToNext()) { + val id = r.getLong("_id") ?: continue + rows += LegacyTaskRow( + id = id, + listId = r.getLong("list_id") ?: continue, + parentId = r.getLong("parent_id"), + masterId = r.getLong("original_instance_id"), + recurrenceId = r.instant("original_instance_time"), + entity = TaskEntity( + listId = 0, + uid = r.getString("_uid") ?: UUID.randomUUID().toString(), + title = r.getString("title"), + description = r.getString("description"), + location = r.getString("location"), + url = r.getString("url"), + color = r.getInt("task_color"), + status = statusFromInt(r.getInt("status")), + percentComplete = r.getInt("percent_complete"), + completedAt = r.instant("completed"), + priority = r.getInt("priority") ?: PRIORITY_NONE, + classification = r.getInt("class"), + dtstart = r.instant("dtstart"), + due = r.instant("due"), + duration = r.getString("duration"), + isAllDay = r.getBoolean("is_allday"), + timezone = r.getString("tz"), + rrule = r.getString("rrule"), + rdate = r.getString("rdate"), + exdate = r.getString("exdate"), + createdAt = r.instant("created"), + lastModified = r.instant("last_modified"), + ), + ) + } + } + + val alarms = mutableListOf() + db.rawQuery("SELECT task_id, mimetype, data0, data1, data2 FROM Properties", null) + .use { cursor -> + val r = CursorColumnReader(cursor) + while (cursor.moveToNext()) { + if (r.getString("mimetype") != ALARM_MIMETYPE) continue + val taskId = r.getLong("task_id") ?: continue + val minutes = r.getString("data0")?.trim()?.toIntOrNull() ?: continue + alarms += LegacyAlarm( + taskId = taskId, + minutesBefore = minutes, + reference = if (r.getString("data1")?.trim() == REFERENCE_START) { + AlarmReference.START + } else { + AlarmReference.DUE + }, + message = r.getString("data2"), + ) + } + } + + return LegacySnapshot(lists, rows, alarms) + } + + // --- writing into Room ---------------------------------------------------- + + /** + * dmfs `list_id`, `parent_id` and `original_instance_id` are old row ids, and + * Room mints its own on insert, so every one of them is remapped through the + * ids the inserts hand back. Tasks are inserted with their links cleared and + * a second pass sets them, because a parent may be a higher `_id` than its + * child. + */ + private fun write(snapshot: LegacySnapshot): ImportCounts { + val listDao = database.taskLists() + val taskDao = database.tasks() + val alarmDao = database.alarms() + + val listIds = snapshot.lists.associate { it.id to listDao.insert(it.entity) } + + // A task whose list is missing is already invisible in dmfs — its tasks + // view inner-joins Lists — so dropping it loses nothing the user could see. + val importable = snapshot.tasks.filter { it.listId in listIds } + val importableIds = importable.mapTo(mutableSetOf()) { it.id } + val taskIds = mutableMapOf() + val inserted = mutableListOf>() + val seen = mutableSetOf>() + + for (row in importable) { + val listId = listIds.getValue(row.listId) + val overrides = row.masterId != null && row.masterId in importableIds + val recurrenceId = row.recurrenceId.takeIf { overrides } + // A duplicate (list, uid, recurrence) would abort the whole import on + // the unique index; a fresh uid costs the row nothing it still has. + val uid = row.entity.uid.takeIf { seen.add(Triple(listId, it, recurrenceId)) } + ?: UUID.randomUUID().toString() + val entity = row.entity.copy(listId = listId, uid = uid, recurrenceId = recurrenceId) + val newId = taskDao.insert(entity) + taskIds[row.id] = newId + inserted += row to entity.copy(id = newId) + } + + for ((row, entity) in inserted) { + val parentId = row.parentId?.let(taskIds::get) + val masterId = row.masterId?.let(taskIds::get) + if (parentId == null && masterId == null) continue + taskDao.update(entity.copy(parentId = parentId, masterId = masterId)) + } + + var alarmCount = 0 + for (alarm in snapshot.alarms) { + val taskId = taskIds[alarm.taskId] ?: continue + alarmDao.insert( + TaskAlarmEntity( + taskId = taskId, + minutesBefore = alarm.minutesBefore, + reference = alarm.reference, + message = alarm.message, + ), + ) + alarmCount++ + } + + return ImportCounts(lists = listIds.size, tasks = taskIds.size, alarms = alarmCount) + } + + private fun verify(written: ImportCounts, before: ImportCounts) { + val after = tableCounts() + check(after.lists - before.lists == written.lists) { + "list count mismatch: ${after.lists - before.lists} != ${written.lists}" + } + check(after.tasks - before.tasks == written.tasks) { + "task count mismatch: ${after.tasks - before.tasks} != ${written.tasks}" + } + check(after.alarms - before.alarms == written.alarms) { + "alarm count mismatch: ${after.alarms - before.alarms} != ${written.alarms}" + } + } + + /** Dropping the lists takes their tasks and alarms with them, by cascade. */ + private fun truncate() { + val listDao = database.taskLists() + listDao.lists().forEach { listDao.delete(it.list.id) } + } + + private fun tableCounts() = ImportCounts( + lists = count("task_lists"), + tasks = count("tasks"), + alarms = count("task_alarms"), + ) + + private fun count(table: String): Int = + database.query("SELECT COUNT(*) FROM $table", null).use { + if (it.moveToFirst()) it.getInt(0) else 0 + } + + // --- the source file ------------------------------------------------------ + + /** + * Renames the dmfs file, sidecars included, to `tasks.db.imported`. Never + * deletes it: for one release it is the only way back if the import turns out + * to be wrong on someone's device. + */ + private fun archive(source: File): Boolean { + val target = File(source.parentFile, ARCHIVE_NAME) + if (!source.renameTo(target)) return false + for (suffix in SIDECARS) { + val sidecar = File(source.path + suffix) + if (sidecar.exists()) sidecar.renameTo(File(target.path + suffix)) + } + return true + } + + private suspend fun markDone() { + dataStore.edit { it[IMPORT_DONE] = true } + } + + private fun CursorColumnReader.instant(name: String): Instant? = + getLong(name)?.let(Instant::fromEpochMilliseconds) + + companion object { + const val LEGACY_NAME = "tasks.db" + const val ARCHIVE_NAME = "tasks.db.imported" + + private const val ALARM_MIMETYPE = "vnd.android.cursor.item/alarm" + private const val REFERENCE_START = "2" + private val SIDECARS = listOf("-journal", "-wal", "-shm") + private val IMPORT_DONE = booleanPreferencesKey("legacy_import_done") + } +} + +private class LegacySnapshot( + val lists: List, + val tasks: List, + val alarms: List, +) + +private class LegacyList(val id: Long, val entity: TaskListEntity) + +private class LegacyTaskRow( + val id: Long, + val listId: Long, + val parentId: Long?, + val masterId: Long?, + val recurrenceId: Instant?, + val entity: TaskEntity, +) + +private class LegacyAlarm( + val taskId: Long, + val minutesBefore: Int, + val reference: AlarmReference, + val message: String?, +) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/AccountDao.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/AccountDao.kt new file mode 100644 index 0000000..e21f14a --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/AccountDao.kt @@ -0,0 +1,30 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Update +import kotlin.time.Instant + +/** Reads and writes over `accounts`. Unused until sync lands. */ +@Dao +interface AccountDao { + + @Query("SELECT * FROM accounts ORDER BY display_name") + fun all(): List + + @Query("SELECT * FROM accounts WHERE id = :accountId") + fun account(accountId: Long): AccountEntity? + + @Insert + fun insert(account: AccountEntity): Long + + @Update + fun update(account: AccountEntity) + + @Query("UPDATE accounts SET last_sync_at = :at, last_sync_error = :error WHERE id = :accountId") + fun recordSync(accountId: Long, at: Instant?, error: String?) + + @Query("DELETE FROM accounts WHERE id = :accountId") + fun delete(accountId: Long): Int +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Converters.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Converters.kt new file mode 100644 index 0000000..f4eae17 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Converters.kt @@ -0,0 +1,38 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import androidx.room.TypeConverter +import de.jeanlucmakiola.agendula.domain.TaskStatus +import de.jeanlucmakiola.agendula.domain.statusFromInt +import de.jeanlucmakiola.agendula.domain.toInt +import kotlin.time.Instant + +/** + * Storage encodings for the entity types SQLite has no column type for. Time is + * epoch millis; [TaskStatus] goes through the `domain` mappers so that numbering + * keeps its single home. + * + * `PRIORITY` deliberately has no converter — it is stored as the raw iCalendar + * integer, because [de.jeanlucmakiola.agendula.domain.Priority] is a lossy + * bucketing and a converter would apply it before the value reaches disk. + */ +object Converters { + + @TypeConverter + fun instantToMillis(value: Instant?): Long? = value?.toEpochMilliseconds() + + @TypeConverter + fun instantFromMillis(value: Long?): Instant? = value?.let(Instant::fromEpochMilliseconds) + + @TypeConverter + fun statusToInt(value: TaskStatus): Int = value.toInt() + + @TypeConverter + fun statusFrom(value: Int): TaskStatus = statusFromInt(value) + + @TypeConverter + fun alarmReferenceToString(value: AlarmReference): String = value.name + + @TypeConverter + fun alarmReferenceFrom(value: String): AlarmReference = + runCatching { AlarmReference.valueOf(value) }.getOrDefault(AlarmReference.DUE) +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/DatabaseCheckpoint.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/DatabaseCheckpoint.kt new file mode 100644 index 0000000..4c3487d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/DatabaseCheckpoint.kt @@ -0,0 +1,35 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import de.jeanlucmakiola.agendula.data.di.ApplicationScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Folds the write-ahead log back into the database file when the app goes to the + * background. + * + * Room runs in WAL mode, and Auto Backup copies files without checkpointing — so + * a `-wal` sidecar can hold writes the backed-up `.db` does not. The backup rules + * carry all three files, which already makes a restore consistent; this narrows + * the window further by ensuring the `.db` alone is usually current, which is what + * a restore onto a device that drops the sidecars falls back to. + */ +@Singleton +class DatabaseCheckpoint @Inject constructor( + private val database: TasksDatabase, + @ApplicationScope private val scope: CoroutineScope, +) : DefaultLifecycleObserver { + + override fun onStop(owner: LifecycleOwner) { + scope.launch(Dispatchers.IO) { + runCatching { + database.openHelper.writableDatabase.query("PRAGMA wal_checkpoint(TRUNCATE)").close() + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Entities.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Entities.kt new file mode 100644 index 0000000..ba58e5b --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Entities.kt @@ -0,0 +1,213 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE +import de.jeanlucmakiola.agendula.domain.TaskStatus +import kotlin.time.Instant + +/** + * A CalDAV account. Empty until sync lands (`docs/SYNC.md` phase 2), but the FK + * from [TaskListEntity] exists from v1 so turning sync on never needs a + * migration. The app password is never stored here — Keystore only. + */ +@Entity(tableName = "accounts") +data class AccountEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "id") val id: Long = 0, + @ColumnInfo(name = "display_name") val displayName: String, + @ColumnInfo(name = "principal_url") val principalUrl: String? = null, + @ColumnInfo(name = "home_set_url") val homeSetUrl: String? = null, + @ColumnInfo(name = "username") val username: String? = null, + @ColumnInfo(name = "last_sync_at") val lastSyncAt: Instant? = null, + @ColumnInfo(name = "last_sync_error") val lastSyncError: String? = null, +) + +/** + * A task list. [accountId] is nullable: `NULL` is a device-only list, and + * attaching one to an account later is a plain `UPDATE` rather than a data + * migration. + * + * Deleting an account detaches its lists (`SET NULL`) instead of deleting them, + * for the same reason [TaskEntity.parentId] does — removing an account is not + * an instruction to destroy the tasks it held. + */ +@Entity( + tableName = "task_lists", + foreignKeys = [ + ForeignKey( + entity = AccountEntity::class, + parentColumns = ["id"], + childColumns = ["account_id"], + onDelete = ForeignKey.SET_NULL, + ), + ], + indices = [Index(value = ["account_id"])], +) +data class TaskListEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "id") val id: Long = 0, + @ColumnInfo(name = "name") val name: String, + /** ARGB. */ + @ColumnInfo(name = "color") val color: Int, + @ColumnInfo(name = "account_id") val accountId: Long? = null, + @ColumnInfo(name = "is_visible", defaultValue = "1") val isVisible: Boolean = true, + @ColumnInfo(name = "is_synced", defaultValue = "1") val isSynced: Boolean = true, + /** CalDAV owner display name. */ + @ColumnInfo(name = "owner") val owner: String? = null, + @ColumnInfo(name = "is_read_only", defaultValue = "0") val isReadOnly: Boolean = false, + /** User ordering. */ + @ColumnInfo(name = "sort_order", defaultValue = "0") val sortOrder: Int = 0, + /** Collection URL, relative to the account root. */ + @ColumnInfo(name = "href") val href: String? = null, + @ColumnInfo(name = "ctag") val ctag: String? = null, + /** RFC 6578 sync token, per collection. */ + @ColumnInfo(name = "sync_token") val syncToken: String? = null, + @ColumnInfo(name = "is_dirty", defaultValue = "0") val isDirty: Boolean = false, +) + +/** + * A task. Series masters *and* `RECURRENCE-ID` overrides live in this table; an + * override is a row with [recurrenceId] set and [masterId] pointing at its + * master, sharing the master's [uid]. + * + * [masterId] and [parentId] are different things: [parentId] is task hierarchy + * (`RELATED-TO;RELTYPE=PARENT`), [masterId] is recurrence. A row can carry both. + */ +@Entity( + tableName = "tasks", + foreignKeys = [ + ForeignKey( + entity = TaskListEntity::class, + parentColumns = ["id"], + childColumns = ["list_id"], + onDelete = ForeignKey.CASCADE, + ), + // Deleting a series takes its overrides with it — they would otherwise be + // unreachable rows that still sync. + ForeignKey( + entity = TaskEntity::class, + parentColumns = ["id"], + childColumns = ["master_id"], + onDelete = ForeignKey.CASCADE, + ), + // Deleting a parent promotes its subtasks to top level rather than + // destroying work the user did not ask to lose. + ForeignKey( + entity = TaskEntity::class, + parentColumns = ["id"], + childColumns = ["parent_id"], + onDelete = ForeignKey.SET_NULL, + ), + ], + indices = [ + Index(value = ["list_id", "is_deleted"]), + Index(value = ["parent_id"]), + Index(value = ["master_id", "recurrence_id"]), + Index(value = ["is_dirty"]), + // An override shares its master's UID, so (list_id, uid) alone would + // reject the very rows recurrence depends on. With recurrence_id NULL on + // the master and set on each override this reads as: one master and at + // most one override per occurrence, per UID, per list. Note SQLite treats + // NULLs as distinct in a unique index, so the master half is a statement + // of intent, not an enforced constraint. + Index(value = ["list_id", "uid", "recurrence_id"], unique = true), + ], +) +data class TaskEntity( + // identity + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "id") val id: Long = 0, + @ColumnInfo(name = "list_id") val listId: Long, + /** RFC 4122 UUID, minted at creation in every mode, synced or not. */ + @ColumnInfo(name = "uid") val uid: String, + @ColumnInfo(name = "href") val href: String? = null, + @ColumnInfo(name = "etag") val etag: String? = null, + + // content + @ColumnInfo(name = "title") val title: String? = null, + @ColumnInfo(name = "description") val description: String? = null, + @ColumnInfo(name = "location") val location: String? = null, + @ColumnInfo(name = "url") val url: String? = null, + /** ARGB override for the list colour. */ + @ColumnInfo(name = "color") val color: Int? = null, + + // state + @ColumnInfo(name = "status", defaultValue = "0") val status: TaskStatus = TaskStatus.NEEDS_ACTION, + @ColumnInfo(name = "percent_complete") val percentComplete: Int? = null, + @ColumnInfo(name = "completed_at") val completedAt: Instant? = null, + /** + * Raw iCalendar `PRIORITY`: 0 none, 1 highest, 9 lowest. Stored unbucketed — + * [de.jeanlucmakiola.agendula.domain.Priority] folds 1–4 into HIGH, so + * converting on the way *in* would rewrite a server's `PRIORITY:3` as `1` and + * lose it on the next round-trip. The bucketing belongs to the mapper, which + * is where the UI needs it. + */ + @ColumnInfo(name = "priority", defaultValue = "0") val priority: Int = PRIORITY_NONE, + /** RFC 5545 `CLASS`: 0 public, 1 private, 2 confidential. */ + @ColumnInfo(name = "classification") val classification: Int? = null, + + // time + @ColumnInfo(name = "dtstart") val dtstart: Instant? = null, + @ColumnInfo(name = "due") val due: Instant? = null, + /** RFC 5545 `DURATION`, verbatim. Mutually exclusive with [due]. */ + @ColumnInfo(name = "duration") val duration: String? = null, + @ColumnInfo(name = "is_all_day", defaultValue = "0") val isAllDay: Boolean = false, + @ColumnInfo(name = "timezone") val timezone: String? = null, + + // recurrence + @ColumnInfo(name = "rrule") val rrule: String? = null, + @ColumnInfo(name = "rdate") val rdate: String? = null, + @ColumnInfo(name = "exdate") val exdate: String? = null, + /** This row's `RECURRENCE-ID` anchor; `NULL` on a master. */ + @ColumnInfo(name = "recurrence_id") val recurrenceId: Instant? = null, + /** The series this row overrides; `NULL` on a master. */ + @ColumnInfo(name = "master_id") val masterId: Long? = null, + + // hierarchy + @ColumnInfo(name = "parent_id") val parentId: Long? = null, + @ColumnInfo(name = "sort_order", defaultValue = "0") val sortOrder: Int = 0, + + // audit + @ColumnInfo(name = "created_at") val createdAt: Instant? = null, + @ColumnInfo(name = "last_modified") val lastModified: Instant? = null, + @ColumnInfo(name = "sequence", defaultValue = "0") val sequence: Int = 0, + + // sync + @ColumnInfo(name = "is_dirty", defaultValue = "0") val isDirty: Boolean = false, + /** Tombstone: deleted locally, still owed to a server. */ + @ColumnInfo(name = "is_deleted", defaultValue = "0") val isDeleted: Boolean = false, + /** + * Raw unfolded iCalendar lines of every property we do not model, re-emitted + * verbatim on write so a round-trip cannot silently lose a field. + */ + @ColumnInfo(name = "unknown_properties") val unknownProperties: String? = null, +) + +/** What [TaskAlarmEntity.minutesBefore] counts back from. */ +enum class AlarmReference { DUE, START } + +/** A reminder lead on a task. Positive [minutesBefore] is *before* [reference]. */ +@Entity( + tableName = "task_alarms", + foreignKeys = [ + ForeignKey( + entity = TaskEntity::class, + parentColumns = ["id"], + childColumns = ["task_id"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index(value = ["task_id"])], +) +data class TaskAlarmEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "id") val id: Long = 0, + @ColumnInfo(name = "task_id") val taskId: Long, + @ColumnInfo(name = "minutes_before") val minutesBefore: Int, + @ColumnInfo(name = "reference", defaultValue = "DUE") val reference: AlarmReference = AlarmReference.DUE, + @ColumnInfo(name = "message") val message: String? = null, +) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Projections.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Projections.kt new file mode 100644 index 0000000..619b0fa --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Projections.kt @@ -0,0 +1,26 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import androidx.room.ColumnInfo +import androidx.room.Embedded + +/** + * A list plus its account's display name, which the domain + * [de.jeanlucmakiola.agendula.domain.TaskList] carries and groups by. + * `null` means a device-only list. + */ +data class TaskListRow( + @Embedded val list: TaskListEntity, + @ColumnInfo(name = "account_display_name") val accountDisplayName: String?, +) + +/** + * A task plus the three columns of its list the domain + * [de.jeanlucmakiola.agendula.domain.Task] carries, so reading a screenful is + * one query rather than one per list. + */ +data class TaskRow( + @Embedded val task: TaskEntity, + @ColumnInfo(name = "list_name") val listName: String, + @ColumnInfo(name = "list_color") val listColor: Int, + @ColumnInfo(name = "account_display_name") val accountDisplayName: String?, +) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapper.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapper.kt new file mode 100644 index 0000000..32c24eb --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapper.kt @@ -0,0 +1,114 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import de.jeanlucmakiola.agendula.domain.LocalAccount +import de.jeanlucmakiola.agendula.domain.Task +import de.jeanlucmakiola.agendula.domain.TaskList +import de.jeanlucmakiola.agendula.domain.export.ExportTask +import de.jeanlucmakiola.agendula.domain.priorityFromICal +import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec +import kotlin.time.Instant + +/** Account type reported for a list attached to one of ours. */ +const val CALDAV_ACCOUNT_TYPE = "caldav" + +/** Maps Room rows to domain models. Pure + testable, like [de.jeanlucmakiola.agendula.data.tasks.TaskMapper]. */ +object RoomTaskMapper { + + fun taskList(row: TaskListRow): TaskList = TaskList( + id = row.list.id, + name = row.list.name, + color = row.list.color, + // TaskList.accountName is non-null and the lists screen groups by it, so a + // list with no account still has to report something to group under. + accountName = row.accountDisplayName ?: LocalAccount.NAME, + accountType = if (row.list.accountId == null) LocalAccount.TYPE else CALDAV_ACCOUNT_TYPE, + isSynced = row.list.isSynced, + isVisible = row.list.isVisible, + owner = row.list.owner, + ) + + /** + * One occurrence of [row]. [occurrenceStart] is the occurrence's + * `RECURRENCE-ID` anchor and `null` for a task that does not recur; + * [start] / [due] are that occurrence's resolved times. + */ + fun task( + row: TaskRow, + occurrenceStart: Instant? = null, + start: Instant? = row.task.dtstart, + due: Instant? = row.task.due, + distanceFromCurrent: Int? = null, + ): Task = Task( + taskId = row.task.id, + listId = row.task.listId, + title = row.task.title.orEmpty(), + description = row.task.description, + location = row.task.location, + url = row.task.url, + priority = priorityFromICal(row.task.priority), + status = row.task.status, + percentComplete = row.task.percentComplete, + start = start, + due = due, + isAllDay = row.task.isAllDay, + timeZone = row.task.timezone, + completedAt = row.task.completedAt, + listColor = row.listColor, + taskColor = row.task.color, + listName = row.listName, + accountName = row.accountDisplayName ?: LocalAccount.NAME, + parentId = row.task.parentId, + isRecurring = row.task.isRecurring, + occurrenceStart = occurrenceStart, + distanceFromCurrent = distanceFromCurrent, + created = row.task.createdAt, + lastModified = row.task.lastModified, + ) + + fun exportTask(task: TaskEntity): ExportTask = ExportTask( + taskId = task.id, + uid = task.uid, + title = task.title.orEmpty(), + description = task.description, + location = task.location, + url = task.url, + priority = priorityFromICal(task.priority), + status = task.status, + percentComplete = task.percentComplete, + start = task.dtstart, + due = task.due, + isAllDay = task.isAllDay, + completedAt = task.completedAt, + created = task.createdAt, + lastModified = task.lastModified, + rrule = task.rrule, + rdate = task.rdate, + parentId = task.parentId?.takeIf { it > 0 }, + ) +} + +/** A row carries a recurrence rule if it has an `RRULE` or an `RDATE`. */ +val TaskEntity.isRecurring: Boolean + get() = !rrule.isNullOrBlank() || !rdate.isNullOrBlank() + +/** + * The series anchor: `DTSTART` when present, else `DUE`. A `VTODO` may carry only + * a due date, and RFC 5545 then anchors the recurrence on it — matching how the + * dmfs provider instantiated the same series. + */ +val TaskEntity.recurrenceAnchor: Instant? + get() = dtstart ?: due + +/** The rule set of this series, or `null` when it does not recur. */ +fun TaskEntity.recurrenceSpec(): RecurrenceSpec? { + if (!isRecurring) return null + val anchor = recurrenceAnchor ?: return null + return RecurrenceSpec( + rrule = rrule, + rdate = rdate, + exdate = exdate, + anchor = anchor, + isAllDay = isAllDay, + timeZone = timezone, + ) +} 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 new file mode 100644 index 0000000..a496df0 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSource.kt @@ -0,0 +1,282 @@ +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.TaskWriteFailedException +import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource +import de.jeanlucmakiola.agendula.domain.Task +import de.jeanlucmakiola.agendula.domain.TaskForm +import de.jeanlucmakiola.agendula.domain.TaskList +import de.jeanlucmakiola.agendula.domain.export.ExportTask +import de.jeanlucmakiola.agendula.domain.recurrence.ExpansionWindow +import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander +import java.time.ZoneId +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Clock +import kotlin.time.Duration.Companion.days +import kotlin.time.Instant + +/** How far either side of now a series is expanded. */ +private val WINDOW_BACK = 365.days +private val WINDOW_FORWARD = 730.days + +private val OBSERVED_TABLES = arrayOf("tasks", "task_lists", "task_alarms", "accounts") + +/** + * [TasksDataSource] over Agendula's own Room store. + * + * The one structural difference from [de.jeanlucmakiola.agendula.data.tasks + * .AndroidTasksDataSource]: there is no materialised instances table, so a + * recurring series is expanded here, at read time, by [RecurrenceExpander]. + * Nothing above this cares — the repository already filters and sorts in Kotlin. + */ +@Singleton +class RoomTasksDataSource @Inject constructor( + private val database: TasksDatabase, +) : TasksDataSource { + + private val clock: Clock = Clock.System + + private val tasks get() = database.tasks() + private val lists get() = database.taskLists() + private val alarms get() = database.alarms() + + // --- reads ---------------------------------------------------------------- + + override fun taskLists(): List = lists.lists().map(RoomTaskMapper::taskList) + + override fun tasks(query: TaskQuery): List { + val now = clock.now() + val overrides = tasks.allOverrides(query.listId).groupBy { it.masterId } + return tasks.tasks(query.listId, query.includeCompleted) + .flatMap { occurrencesOf(it, overrides[it.task.id].orEmpty(), now) } + .filter { query.includeCompleted || !it.isClosed } + } + + override fun task(taskId: Long): Task? { + val row = tasks.task(taskId) ?: return null + // An override row is one occurrence in its own right; it names the + // occurrence it replaces rather than expanding to a series. + row.task.recurrenceId?.let { return RoomTaskMapper.task(row, occurrenceStart = it) } + val now = clock.now() + val occurrences = occurrencesOf(row, tasks.overrides(taskId), now) + return occurrences.firstOrNull { it.distanceFromCurrent == 0 } ?: occurrences.firstOrNull() + } + + override fun subtasks(parentTaskId: Long): List { + val now = clock.now() + return tasks.subtasks(parentTaskId) + .flatMap { occurrencesOf(it, tasks.overrides(it.task.id), now) } + } + + override fun exportTasks(listId: Long): List = + tasks.exportTasks(listId).map(RoomTaskMapper::exportTask) + + override fun alarms(): Map = + alarms.all().associate { + it.taskId to TaskReminder( + minutesBefore = it.minutesBefore, + fromStart = it.reference == AlarmReference.START, + ) + } + + /** + * Every occurrence of [row] inside the expansion window, with any + * `RECURRENCE-ID` override substituted for the occurrence it replaces. + * + * A non-recurring task is its own single occurrence and carries a null + * [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] + if (override != null) { + RoomTaskMapper.task( + row = row.copy(task = override), + occurrenceStart = anchor, + start = override.dtstart, + due = override.due, + distanceFromCurrent = distances[index], + ) + } else { + val (start, due) = occurrenceTimes(row.task, anchor) + RoomTaskMapper.task( + row = row, + occurrenceStart = anchor, + start = start, + due = due, + distanceFromCurrent = distances[index], + ) + } + } + } + + /** + * 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): Long { + if (lists.exists(form.listId) == 0) throw TaskWriteFailedException("insert task: no list ${form.listId}") + val entity = TaskFormWriter.newTask(form, uid = UUID.randomUUID().toString(), now = clock.now(), tzId = zone()) + return tasks.insert(entity) + } + + override fun updateTask(taskId: Long, form: TaskForm) { + val current = tasks.entity(taskId) ?: throw TaskWriteFailedException("update task $taskId") + tasks.update(TaskFormWriter.apply(current, form, clock.now(), zone())) + } + + /** + * Writes one occurrence as a `RECURRENCE-ID` override — RFC 5545's model, and + * what every other CalDAV client expects to receive. The dmfs provider + * detached the occurrence into a brand-new task with its own UID instead, + * 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) { + val master = tasks.entity(taskId) ?: throw TaskWriteFailedException("update instance $taskId") + val now = clock.now() + val existing = tasks.override(taskId, occurrenceStart) + if (existing != null) { + tasks.update(TaskFormWriter.apply(existing, form, now, zone())) + return + } + val (start, due) = occurrenceTimes(master, occurrenceStart) + val fork = TaskFormWriter.apply( + newOverride(master, taskId, occurrenceStart, start, due), + form, + now, + zone(), + ) + // The list and parent come from the master: moving one occurrence between + // lists or parents is not something the override model expresses. + val id = tasks.insert(fork.copy(listId = master.listId, parentId = master.parentId)) + alarms.forTask(taskId).firstOrNull()?.let { alarms.replaceForTask(id, it) } + } + + override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) { + alarms.replaceForTask( + taskId, + minutesBeforeDue?.let { TaskAlarmEntity(taskId = taskId, minutesBefore = it) }, + ) + } + + override fun setCompleted(taskId: Long, completed: Boolean) { + val current = tasks.entity(taskId) ?: throw TaskWriteFailedException("complete task $taskId") + tasks.update(TaskFormWriter.completed(current, completed, clock.now())) + } + + /** + * Ticking one occurrence forks a `RECURRENCE-ID` override carrying the + * completion — the same model [updateInstance] writes. Writing the status onto + * the master instead would close the series: the master is what + * [TaskDao.tasks] filters on, so every occurrence, past and future, would + * leave every list at once. + */ + override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) { + val master = tasks.entity(taskId) ?: throw TaskWriteFailedException("complete instance $taskId") + // Not a series master — an override, or a plain task the caller handed an + // anchor for. Either way this row *is* the occurrence. + if (master.recurrenceSpec() == null) return setCompleted(taskId, completed) + + val now = clock.now() + tasks.override(taskId, occurrenceStart)?.let { + tasks.update(TaskFormWriter.completed(it, completed, now)) + return + } + val (start, due) = occurrenceTimes(master, occurrenceStart) + val fork = TaskFormWriter.completed(newOverride(master, taskId, occurrenceStart, start, due), completed, now) + val id = tasks.insert(fork) + alarms.forTask(taskId).firstOrNull()?.let { alarms.replaceForTask(id, it) } + } + + /** + * A blank override row for one occurrence of [master]: same UID (that is what + * makes it an override rather than a separate task), the series fields + * stripped, and no `href`/`etag` because the server has never seen it. + */ + private fun newOverride( + master: TaskEntity, + masterId: Long, + occurrenceStart: Instant, + start: Instant?, + due: Instant?, + ): TaskEntity = master.copy( + id = 0, + masterId = masterId, + recurrenceId = occurrenceStart, + dtstart = start, + due = due, + rrule = null, + rdate = null, + exdate = null, + href = null, + etag = null, + ) + + /** + * Hard delete for a row no server knows about, tombstone for one that is + * still owed to a collection. `master_id` cascades, so deleting a series + * takes its overrides with it. + */ + override fun deleteTask(taskId: Long) { + val current = tasks.entity(taskId) ?: return + val listAccount = lists.entity(current.listId)?.accountId + if (listAccount == null) tasks.delete(taskId) else tasks.markDeleted(taskId, clock.now()) + } + + override fun createLocalList(name: String, color: Int): Long = + lists.insert(TaskListEntity(name = name.trim(), color = color)) + + override fun updateList(listId: Long, name: String, color: Int) { + val current = lists.entity(listId) ?: throw TaskWriteFailedException("update list $listId") + // Only an account-backed collection owes a server a PROPPATCH; a + // device-only list has nothing to be dirty for. + lists.update( + current.copy( + name = name.trim(), + color = color, + isDirty = current.accountId != null, + ), + ) + } + + /** `tasks.list_id` is `ON DELETE CASCADE`, so the list's tasks go with it. */ + override fun deleteList(listId: Long) = lists.delete(listId) + + // --- observation ---------------------------------------------------------- + + override fun registerObserver(onChange: () -> Unit): AutoCloseable { + val observer = object : InvalidationTracker.Observer(OBSERVED_TABLES) { + override fun onInvalidated(tables: Set) = onChange() + } + database.invalidationTracker.addObserver(observer) + return AutoCloseable { database.invalidationTracker.removeObserver(observer) } + } + + private fun zone(): String = ZoneId.systemDefault().id +} 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 new file mode 100644 index 0000000..bd52710 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskAlarmDao.kt @@ -0,0 +1,35 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Transaction + +/** Reads and writes over `task_alarms`. */ +@Dao +interface TaskAlarmDao { + + /** Every reminder in the store, for one scheduler pass. */ + @Query("SELECT * FROM task_alarms") + fun all(): List + + @Query("SELECT * FROM task_alarms WHERE task_id = :taskId") + fun forTask(taskId: Long): List + + @Insert + fun insert(alarm: TaskAlarmEntity): Long + + @Query("DELETE FROM task_alarms WHERE task_id = :taskId") + fun deleteForTask(taskId: Long): Int + + /** + * Set the task's only reminder, or clear it with `null`. The row that lands is + * always a new one — the id is cleared so an alarm lifted off another task + * (forking an occurrence copies the master's) inserts instead of colliding. + */ + @Transaction + fun replaceForTask(taskId: Long, alarm: TaskAlarmEntity?) { + deleteForTask(taskId) + alarm?.let { insert(it.copy(id = 0, taskId = taskId)) } + } +} 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 new file mode 100644 index 0000000..c5747c5 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskDao.kt @@ -0,0 +1,134 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Update +import de.jeanlucmakiola.agendula.domain.TaskStatus +import kotlin.time.Instant + +/** + * Reads and writes over `tasks`. + * + * Reads split masters from overrides on purpose: [tasks] returns the rows a + * recurrence expander expands (a non-recurring task is its own single + * occurrence), and [allOverrides] / [overrides] return the + * `RECURRENCE-ID` rows that replace individual occurrences. Nothing here + * expands anything — that is phase 2's job, in Kotlin. + */ +@Dao +interface TaskDao { + + // --- reads ---------------------------------------------------------------- + + /** + * Master (and non-recurring) rows, optionally narrowed to one list. Closed + * tasks — `COMPLETED` and `CANCELLED` — are excluded unless + * [includeCompleted]; tombstones always are. + */ + @Query( + """ + SELECT t.*, l.name AS list_name, l.color AS list_color, + a.display_name AS account_display_name + FROM tasks t + JOIN task_lists l ON l.id = t.list_id + LEFT JOIN accounts a ON a.id = l.account_id + WHERE t.is_deleted = 0 + AND t.master_id IS NULL + AND (:listId IS NULL OR t.list_id = :listId) + AND (:includeCompleted = 1 OR t.status NOT IN (2, 3)) + """ + ) + fun tasks(listId: Long?, includeCompleted: Boolean): List + + @Query( + """ + SELECT t.*, l.name AS list_name, l.color AS list_color, + a.display_name AS account_display_name + FROM tasks t + JOIN task_lists l ON l.id = t.list_id + LEFT JOIN accounts a ON a.id = l.account_id + WHERE t.id = :taskId AND t.is_deleted = 0 + """ + ) + fun task(taskId: Long): TaskRow? + + @Query( + """ + SELECT t.*, l.name AS list_name, l.color AS list_color, + a.display_name AS account_display_name + FROM tasks t + JOIN task_lists l ON l.id = t.list_id + LEFT JOIN accounts a ON a.id = l.account_id + WHERE t.parent_id = :parentTaskId AND t.is_deleted = 0 AND t.master_id IS NULL + """ + ) + fun subtasks(parentTaskId: Long): List + + @Query("SELECT * FROM tasks WHERE id = :taskId") + fun entity(taskId: Long): TaskEntity? + + /** + * Every override, optionally narrowed to one list — read alongside [tasks] so + * expansion can replace the occurrences they override in one pass rather than + * querying per series. + */ + @Query( + """ + SELECT * FROM tasks + WHERE master_id IS NOT NULL AND is_deleted = 0 + AND (:listId IS NULL OR list_id = :listId) + """ + ) + fun allOverrides(listId: Long?): List + + @Query("SELECT * FROM tasks WHERE master_id = :masterId AND is_deleted = 0") + fun overrides(masterId: Long): List + + @Query( + "SELECT * FROM tasks WHERE master_id = :masterId AND recurrence_id IS :recurrenceId AND is_deleted = 0" + ) + fun override(masterId: Long, recurrenceId: Instant?): TaskEntity? + + @Query("SELECT * FROM tasks WHERE list_id = :listId AND uid = :uid AND recurrence_id IS :recurrenceId") + fun byUid(listId: Long, uid: String, recurrenceId: Instant? = null): TaskEntity? + + /** Masters only, tombstones excluded — what an `.ics` export writes. */ + @Query("SELECT * FROM tasks WHERE list_id = :listId AND is_deleted = 0 AND master_id IS NULL") + fun exportTasks(listId: Long): List + + @Query("SELECT * FROM tasks WHERE is_dirty = 1") + fun dirty(): List + + // --- writes --------------------------------------------------------------- + + @Insert + fun insert(task: TaskEntity): Long + + @Update + fun update(task: TaskEntity): Int + + @Query( + """ + UPDATE tasks SET status = :status, percent_complete = :percentComplete, + completed_at = :completedAt, last_modified = :lastModified, is_dirty = :dirty + WHERE id = :taskId + """ + ) + fun setCompletion( + taskId: Long, + status: TaskStatus, + percentComplete: Int?, + completedAt: Instant?, + lastModified: Instant?, + dirty: Boolean, + ): Int + + /** Hard delete. Used when the row was never on a server. */ + @Query("DELETE FROM tasks WHERE id = :taskId") + fun delete(taskId: Long): Int + + /** Tombstone, for a row a server still knows about. */ + @Query("UPDATE tasks SET is_deleted = 1, is_dirty = 1, last_modified = :at WHERE id = :taskId") + fun markDeleted(taskId: Long, at: Instant?): Int +} 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 new file mode 100644 index 0000000..5e81d33 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriter.kt @@ -0,0 +1,91 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import de.jeanlucmakiola.agendula.domain.TaskForm +import de.jeanlucmakiola.agendula.domain.TaskStatus +import de.jeanlucmakiola.agendula.domain.toICal +import kotlin.time.Instant + +private const val MILLIS_PER_DAY = 24L * 60 * 60 * 1000 + +/** Floor to UTC midnight when [allDay], else pass through unchanged. */ +internal fun Instant.forAllDay(allDay: Boolean): Instant = + if (!allDay) this + else Instant.fromEpochMilliseconds( + Math.floorDiv(toEpochMilliseconds(), MILLIS_PER_DAY) * MILLIS_PER_DAY, + ) + +/** + * Applies a [TaskForm] to a [TaskEntity]. Pure, so the semantics below are + * testable on the JVM without a database. + * + * This is the Room counterpart of + * [de.jeanlucmakiola.agendula.data.tasks.TaskWriteMapper], which stays for + * External mode. It is a separate object rather than a shared one because most + * of what that mapper does is work around the provider — clearing `DURATION` + * because the provider validates a merged row, writing `STATUS` both ways + * because the provider auto-completes at 100% but will not reopen below it. Here + * those rules are ours to state directly. + */ +object TaskFormWriter { + + /** A brand-new task. [uid] is minted by the caller and never null. */ + fun newTask(form: TaskForm, uid: String, now: Instant, tzId: String): TaskEntity = + apply( + TaskEntity(listId = form.listId, uid = uid, createdAt = now), + form, + now, + tzId, + ) + + /** [current] with [form] applied. Identity, recurrence and sync columns are left alone. */ + fun apply(current: TaskEntity, form: TaskForm, now: Instant, tzId: String): TaskEntity { + val percent = form.percentComplete?.coerceIn(0, 100) + val timed = !form.isAllDay && (form.start != null || form.due != null) + return current.copy( + listId = form.listId, + title = form.title.trim(), + description = form.description?.trim()?.ifBlank { null }, + priority = form.priority.toICal(), + percentComplete = percent, + status = statusFor(percent, current.status), + completedAt = completedAtFor(percent, current, now), + dtstart = form.start?.forAllDay(form.isAllDay), + due = form.due?.forAllDay(form.isAllDay), + // DUE and DURATION are mutually exclusive (RFC 5545 §3.6.2). + duration = null, + isAllDay = form.isAllDay, + timezone = if (timed) tzId else null, + parentId = form.parentId?.takeIf { it > 0 }, + lastModified = now, + isDirty = true, + ) + } + + /** The completion triple, for the standalone complete toggle. */ + fun completed(current: TaskEntity, completed: Boolean, now: Instant): TaskEntity = current.copy( + status = if (completed) TaskStatus.COMPLETED else TaskStatus.NEEDS_ACTION, + percentComplete = if (completed) 100 else null, + completedAt = if (completed) now else null, + lastModified = now, + isDirty = true, + ) + + /** + * A form carrying no percent leaves status alone — the standalone toggle stays + * authoritative. Otherwise progress and status move together in both + * directions, which is the asymmetry the provider never had: it auto-completed + * at 100% but would not reopen below it, stranding a task "done at 75%". + */ + private fun statusFor(percent: Int?, current: TaskStatus): TaskStatus = when { + percent == null -> current + percent >= 100 -> TaskStatus.COMPLETED + percent > 0 -> TaskStatus.IN_PROCESS + else -> TaskStatus.NEEDS_ACTION + } + + private fun completedAtFor(percent: Int?, current: TaskEntity, now: Instant): Instant? = when { + percent == null -> current.completedAt + percent >= 100 -> current.completedAt ?: now + else -> null + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskListDao.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskListDao.kt new file mode 100644 index 0000000..f6017a6 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskListDao.kt @@ -0,0 +1,51 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Update + +/** Reads and writes over `task_lists`. Synchronous, like the seam above it. */ +@Dao +interface TaskListDao { + + @Query( + """ + SELECT l.*, a.display_name AS account_display_name + FROM task_lists l LEFT JOIN accounts a ON a.id = l.account_id + ORDER BY a.display_name, l.sort_order, l.name + """ + ) + fun lists(): List + + @Query( + """ + SELECT l.*, a.display_name AS account_display_name + FROM task_lists l LEFT JOIN accounts a ON a.id = l.account_id + WHERE l.id = :listId + """ + ) + fun list(listId: Long): TaskListRow? + + @Query("SELECT * FROM task_lists WHERE id = :listId") + fun entity(listId: Long): TaskListEntity? + + @Query("SELECT COUNT(*) FROM task_lists WHERE id = :listId") + fun exists(listId: Long): Int + + @Insert + fun insert(list: TaskListEntity): Long + + @Update + fun update(list: TaskListEntity) + + @Query("UPDATE task_lists SET is_visible = :visible WHERE id = :listId") + fun setVisible(listId: Long, visible: Boolean) + + /** Attach a list to an account, or detach it with `null`. */ + @Query("UPDATE task_lists SET account_id = :accountId WHERE id = :listId") + fun setAccount(listId: Long, accountId: Long?) + + @Query("DELETE FROM task_lists WHERE id = :listId") + fun delete(listId: Long) +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabase.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabase.kt new file mode 100644 index 0000000..9b8aabf --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabase.kt @@ -0,0 +1,34 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.TypeConverters + +/** + * Agendula's own task store (`docs/OWN-STORE.md`). Four tables, designed from + * what the app actually reads and writes plus RFC 5545's `VTODO`. + * + * Schemas are exported to `app/schemas/` and committed, so a future version can + * be migration-tested against this one. + */ +@Database( + entities = [ + AccountEntity::class, + TaskListEntity::class, + TaskEntity::class, + TaskAlarmEntity::class, + ], + version = 1, + exportSchema = true, +) +@TypeConverters(Converters::class) +abstract class TasksDatabase : RoomDatabase() { + abstract fun taskLists(): TaskListDao + abstract fun tasks(): TaskDao + abstract fun alarms(): TaskAlarmDao + abstract fun accounts(): AccountDao + + companion object { + const val NAME = "agendula-tasks.db" + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/AllDayTime.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/AllDayTime.kt new file mode 100644 index 0000000..e86fca0 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/AllDayTime.kt @@ -0,0 +1,42 @@ +package de.jeanlucmakiola.agendula.domain + +import java.time.ZoneId +import java.time.ZoneOffset +import kotlin.time.Instant + +/** + * All-day tasks are date-only in iCalendar. OpenTasks reads them back through + * `DateTime.toAllDay()`, which discards the time-of-day and resolves the + * remaining date against UTC — so the storage convention is **UTC midnight of + * the intended calendar date, with a null timezone**. Timed tasks, by contrast, + * are ordinary instants rendered in the device's zone. + * + * These two conventions disagree about which day a given instant is, which is + * why every all-day value needs an explicit conversion rather than a raw + * `Instant` passed straight through. + */ + +/** UTC midnight of [date] — the storage form for an all-day value. */ +fun allDayInstantOf(date: java.time.LocalDate): Instant = + Instant.fromEpochMilliseconds(date.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()) + +/** + * The calendar date this instant denotes: read in UTC for [allDay] values, + * in [zone] for timed ones. + */ +fun Instant.calendarDate(allDay: Boolean, zone: ZoneId = ZoneId.systemDefault()): java.time.LocalDate = + java.time.Instant.ofEpochMilli(toEpochMilliseconds()) + .atZone(if (allDay) ZoneOffset.UTC else zone) + .toLocalDate() + +/** + * Move an instant across the two conventions when the all-day switch flips, so + * the day the user is looking at stays put. Without this, toggling all-day off + * turns a UTC-midnight value into "02:00" in Berlin (or the previous day, 19:00, + * in New York) — reading to the user as "the time reset itself". + */ +fun Instant.rebasedForAllDay(allDay: Boolean, zone: ZoneId = ZoneId.systemDefault()): Instant = + if (allDay) allDayInstantOf(calendarDate(allDay = false, zone = zone)) + else Instant.fromEpochMilliseconds( + calendarDate(allDay = true).atStartOfDay(zone).toInstant().toEpochMilli(), + ) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/Models.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/Models.kt index 47e8e68..5f16df4 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/domain/Models.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/Models.kt @@ -1,6 +1,5 @@ package de.jeanlucmakiola.agendula.domain -import de.jeanlucmakiola.agendula.data.tasks.TasksContract import kotlin.time.Instant /** A task list (the `tasklists` table). Lists group under their account. */ @@ -15,7 +14,8 @@ data class TaskList( val owner: String?, ) { /** A device-only list Agendula (or another app) created locally, not synced. */ - val isLocal: Boolean get() = accountType == TasksContract.LOCAL_ACCOUNT_TYPE + val isLocal: Boolean + get() = accountType == LocalAccount.TYPE || accountType == LocalAccount.DMFS_TYPE } enum class TaskStatus { NEEDS_ACTION, IN_PROCESS, COMPLETED, CANCELLED } @@ -24,11 +24,11 @@ enum class TaskStatus { NEEDS_ACTION, IN_PROCESS, COMPLETED, CANCELLED } enum class Priority { NONE, LOW, MEDIUM, HIGH } /** - * A task occurrence as read from the `instances` view. [id] is the instance row - * id; [taskId] is the underlying `tasks._id` and the stable target for edits. + * One occurrence of a task. [taskId] is the underlying task row and the stable + * target for edits and navigation; [occurrenceStart] distinguishes occurrences of + * the same series. */ data class Task( - val id: Long, val taskId: Long, val listId: Long, val title: String, @@ -48,7 +48,22 @@ data class Task( val listName: String?, val accountName: String?, val parentId: Long?, + /** + * This row carries a recurrence rule, so it is one occurrence of a series and + * [start]/[due] are that occurrence's resolved times — *not* the master's + * anchor. Edits go through + * [de.jeanlucmakiola.agendula.data.tasks.TasksDataSource.updateInstance], which + * forks a `RECURRENCE-ID` override instead of re-anchoring the series. + */ val isRecurring: Boolean, + /** + * This occurrence's `RECURRENCE-ID` anchor — what identifies it within its + * series — or `null` when the task does not recur. Together with [taskId] it + * is a stable, collision-free identity for an occurrence, which is what list + * keys and [de.jeanlucmakiola.agendula.data.tasks.TasksDataSource.updateInstance] + * address it by. + */ + val occurrenceStart: Instant? = null, val distanceFromCurrent: Int?, val created: Instant?, val lastModified: Instant?, @@ -65,6 +80,15 @@ data class Task( val isSubtask: Boolean get() = parentId != null && parentId > 0 /** The task's own colour if set, else the list colour. */ val effectiveColor: Int get() = taskColor ?: listColor + + /** + * Stable identity for a lazy-list key. Two occurrences of one series can show + * up in the same list, so [taskId] alone is not unique — and folding + * `(taskId, occurrenceStart)` into a Long could collide, which as a Compose + * key is a visible bug. + */ + val occurrenceKey: String + get() = if (occurrenceStart == null) "$taskId" else "$taskId@${occurrenceStart.toEpochMilliseconds()}" } /** Detail bundle: a task, its parent (if it's a subtask), and its direct children. */ @@ -85,22 +109,22 @@ fun priorityFromICal(value: Int?): Priority = when { /** Representative iCalendar priority for a bucket (1 high, 5 medium, 9 low). */ fun Priority.toICal(): Int = when (this) { - Priority.NONE -> TasksContract.PRIORITY_NONE + Priority.NONE -> PRIORITY_NONE Priority.HIGH -> 1 Priority.MEDIUM -> 5 Priority.LOW -> 9 } fun statusFromInt(value: Int?): TaskStatus = when (value) { - TasksContract.STATUS_IN_PROCESS -> TaskStatus.IN_PROCESS - TasksContract.STATUS_COMPLETED -> TaskStatus.COMPLETED - TasksContract.STATUS_CANCELLED -> TaskStatus.CANCELLED + ICalStatus.IN_PROCESS -> TaskStatus.IN_PROCESS + ICalStatus.COMPLETED -> TaskStatus.COMPLETED + ICalStatus.CANCELLED -> TaskStatus.CANCELLED else -> TaskStatus.NEEDS_ACTION } fun TaskStatus.toInt(): Int = when (this) { - TaskStatus.NEEDS_ACTION -> TasksContract.STATUS_NEEDS_ACTION - TaskStatus.IN_PROCESS -> TasksContract.STATUS_IN_PROCESS - TaskStatus.COMPLETED -> TasksContract.STATUS_COMPLETED - TaskStatus.CANCELLED -> TasksContract.STATUS_CANCELLED + TaskStatus.NEEDS_ACTION -> ICalStatus.NEEDS_ACTION + TaskStatus.IN_PROCESS -> ICalStatus.IN_PROCESS + TaskStatus.COMPLETED -> ICalStatus.COMPLETED + TaskStatus.CANCELLED -> ICalStatus.CANCELLED } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskConstants.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskConstants.kt new file mode 100644 index 0000000..fd3e8c3 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskConstants.kt @@ -0,0 +1,30 @@ +package de.jeanlucmakiola.agendula.domain + +/** + * iCalendar `STATUS` values for a `VTODO`, as integers. + * + * These live in `domain` rather than being read out of a provider contract: the + * numbering is Agendula's own storage encoding as much as it is dmfs's, and the + * domain layer must not depend on the data layer to map its own enums. + */ +object ICalStatus { + const val NEEDS_ACTION = 0 + const val IN_PROCESS = 1 + const val COMPLETED = 2 + const val CANCELLED = 3 +} + +/** Priority 0 means "no priority"; 1 is highest, 9 lowest (RFC 5545 §3.8.1.9). */ +const val PRIORITY_NONE = 0 + +/** How a device-only list identifies its (non-existent) account. */ +object LocalAccount { + /** Shown as the section header above device-only lists. */ + const val NAME = "Local" + + /** What Agendula's own store reports for a list with no account. */ + const val TYPE = "local" + + /** What a dmfs-derived provider reports in External mode. */ + const val DMFS_TYPE = "org.dmfs.account.LOCAL" +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/export/ExportModels.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/export/ExportModels.kt new file mode 100644 index 0000000..153a34c --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/export/ExportModels.kt @@ -0,0 +1,66 @@ +package de.jeanlucmakiola.agendula.domain.export + +import de.jeanlucmakiola.agendula.domain.Priority +import de.jeanlucmakiola.agendula.domain.TaskStatus +import kotlin.time.Instant + +/** + * One task as it goes out to iCalendar — a **master** task, not an occurrence. + * + * Deliberately not [de.jeanlucmakiola.agendula.domain.Task]. That model is read + * from the `instances` view, where a recurring task appears once per occurrence + * with resolved times and no rule; exporting from it would write the same task + * fifty times and lose the RRULE that generated them. Export reads the `tasks` + * table instead, and needs two fields the UI never asks for ([uid], [rrule]). + */ +data class ExportTask( + /** `tasks._id` — the fallback identity when [uid] is absent. */ + val taskId: Long, + /** + * The iCalendar UID, or `null` for a task created on this device and never + * synced. The dmfs provider only lets a *sync adapter* assign one, so in Local + * mode this is null for everything — see [ICalendarWriter.uidFor], which + * synthesises a stable substitute rather than emitting a VTODO with no UID. + */ + val uid: String?, + val title: String, + val description: String?, + val location: String?, + val url: String?, + val priority: Priority, + val status: TaskStatus, + val percentComplete: Int?, + val start: Instant?, + val due: Instant?, + val isAllDay: Boolean, + val completedAt: Instant?, + val created: Instant?, + val lastModified: Instant?, + /** Raw `RRULE` value as stored, without the `RRULE:` name. Null when non-recurring. */ + val rrule: String?, + /** Raw `RDATE` value as stored. Null when absent. */ + val rdate: String?, + /** `tasks._id` of the parent, for `RELATED-TO;RELTYPE=PARENT`. */ + val parentId: Long?, +) + +/** A task list and everything in it, ready to become one `.ics` document. */ +data class ExportList( + val listId: Long, + val name: String, + val accountName: String, + val tasks: List, +) + +/** A single file the export produced: [fileName] and its finished bytes. */ +data class ExportDocument( + val fileName: String, + val content: ByteArray, +) { + // ByteArray gets identity equals/hashCode, which makes this data class lie. + override fun equals(other: Any?): Boolean = + this === other || + (other is ExportDocument && fileName == other.fileName && content.contentEquals(other.content)) + + override fun hashCode(): Int = 31 * fileName.hashCode() + content.contentHashCode() +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/export/ICalendarWriter.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/export/ICalendarWriter.kt new file mode 100644 index 0000000..520fa7a --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/export/ICalendarWriter.kt @@ -0,0 +1,204 @@ +package de.jeanlucmakiola.agendula.domain.export + +import de.jeanlucmakiola.agendula.domain.Priority +import de.jeanlucmakiola.agendula.domain.TaskStatus +import de.jeanlucmakiola.agendula.domain.calendarDate +import de.jeanlucmakiola.agendula.domain.toICal +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import kotlin.time.Instant + +/** + * Writes a task list as an RFC 5545 `VCALENDAR` of `VTODO` components. + * + * Pure Kotlin and deliberately free of any Android type, so the format — the part + * that decides whether an exported backup can actually be read again — is + * unit-testable on the JVM. Serialising tasks is task-domain and stays here; the + * SAF/file plumbing that carries the bytes out is not, and lives in the data + * layer (and is the piece `docs/STORAGE-AND-SYNC.md` marks as a floret-kit + * candidate). + * + * **Times are always written in UTC.** Emitting a local `TZID` would oblige us to + * also emit a matching `VTIMEZONE` component with its full transition rules, and + * a `TZID` referencing an absent definition is what actually breaks importers. UTC + * is unambiguous and universally accepted, so the exported instant is exact even + * though the original wall-clock zone is not carried. All-day values keep their + * `VALUE=DATE` form and stay date-only, which is the only representation that + * survives a timezone change intact. + */ +object ICalendarWriter { + + private const val PRODUCT_ID = "-//Jean-Luc Makiola//Agendula//EN" + + /** RFC 5545 caps a content line at 75 octets, excluding the CRLF. */ + private const val MAX_LINE_OCTETS = 75 + + private val DATE = DateTimeFormatter.ofPattern("yyyyMMdd") + private val DATE_TIME_UTC = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'") + + /** Serialises [list] to a complete `.ics` document. */ + fun write(list: ExportList): String = buildString { + line("BEGIN:VCALENDAR") + line("VERSION:2.0") + line("PRODID:$PRODUCT_ID") + line("CALSCALE:GREGORIAN") + // Non-standard but near-universally understood, and the only way the list's + // name survives into a calendar app. Importers that don't know it skip it. + property("X-WR-CALNAME", list.name) + + // Parents must be addressable by UID, and a subtask may appear before its + // parent in the list, so resolve every id up front. + val uidsById = list.tasks.associate { it.taskId to uidFor(it) } + list.tasks.forEach { task -> writeTask(task, uidsById) } + + line("END:VCALENDAR") + } + + /** + * The UID to write for [task]. + * + * Local tasks have none, because nothing assigns one: the provider never + * generates a `_uid` itself, and our write path does not set it either, so in + * Local mode every task arrives here with `uid == null`. + * + * Note this is a gap we leave open, not one the provider imposes. + * `processors/tasks/Validating.java:92-96` restricts `_uid` to sync adapters + * on *update* only; `insert` does not check it, so any caller may assign a UID + * at creation. Doing that would be strictly better than synthesising here — + * see `docs/SYNC.md`, where it is a phase-1 item, because a real UID minted at + * creation is what lets a local task later be pushed to CalDAV without + * duplicating. + * + * Until then: a VTODO without a UID is invalid and, worse, un-mergeable — + * re-importing a backup would duplicate every task instead of matching it. So + * we synthesise one from the row id, which is stable for as long as the row + * is, and tag it with our own domain so a synthesised UID is recognisable as + * such. + */ + fun uidFor(task: ExportTask): String = + task.uid?.takeIf { it.isNotBlank() } ?: "agendula-${task.taskId}@jeanlucmakiola.de" + + private fun StringBuilder.writeTask(task: ExportTask, uidsById: Map) { + line("BEGIN:VTODO") + property("UID", uidFor(task)) + // DTSTAMP is mandatory. It means "when this representation was written", + // which for an export is now — not the task's own timestamps. + property("DTSTAMP", formatUtc(Instant.fromEpochMilliseconds(System.currentTimeMillis()))) + property("SUMMARY", task.title) + + task.description?.takeIf { it.isNotBlank() }?.let { property("DESCRIPTION", it) } + task.location?.takeIf { it.isNotBlank() }?.let { property("LOCATION", it) } + // URL is a URI, not TEXT: it must not be escaped like one. + task.url?.takeIf { it.isNotBlank() }?.let { rawProperty("URL", it) } + + task.start?.let { dateProperty("DTSTART", it, task.isAllDay) } + task.due?.let { dateProperty("DUE", it, task.isAllDay) } + task.created?.let { rawProperty("CREATED", formatUtc(it)) } + task.lastModified?.let { rawProperty("LAST-MODIFIED", formatUtc(it)) } + // COMPLETED is defined as UTC date-time even for an all-day task. + task.completedAt?.let { rawProperty("COMPLETED", formatUtc(it)) } + + rawProperty("STATUS", task.status.toICalName()) + if (task.priority != Priority.NONE) rawProperty("PRIORITY", task.priority.toICal().toString()) + task.percentComplete?.coerceIn(0, 100)?.let { rawProperty("PERCENT-COMPLETE", it.toString()) } + + // Passed through as stored. The provider keeps these in iCalendar form + // already, and re-deriving them would risk changing what the user's + // recurrence actually means. + task.rrule?.takeIf { it.isNotBlank() }?.let { rawProperty("RRULE", it) } + task.rdate?.takeIf { it.isNotBlank() }?.let { rawProperty("RDATE", it) } + + // Only emit the link when the parent is in this same document; a + // RELATED-TO pointing outside the file would dangle on import. + task.parentId?.let { uidsById[it] }?.let { + property("RELATED-TO;RELTYPE=PARENT", it) + } + + line("END:VTODO") + } + + private fun StringBuilder.dateProperty(name: String, instant: Instant, allDay: Boolean) { + if (allDay) { + // Read in UTC, matching the storage convention (see AllDayTime): an + // all-day value *is* UTC midnight of the intended calendar date. + rawProperty("$name;VALUE=DATE", instant.calendarDate(allDay = true).format(DATE)) + } else { + rawProperty(name, formatUtc(instant)) + } + } + + private fun formatUtc(instant: Instant): String = + java.time.Instant.ofEpochMilli(instant.toEpochMilliseconds()) + .atZone(ZoneOffset.UTC) + .format(DATE_TIME_UTC) + + /** A property whose value is TEXT, and so must be escaped. */ + private fun StringBuilder.property(name: String, value: String) = + line("$name:${escapeText(value)}") + + /** A property whose value is already in its final form (dates, numbers, URIs, rules). */ + private fun StringBuilder.rawProperty(name: String, value: String) = line("$name:$value") + + private fun StringBuilder.line(content: String) { + append(fold(content)) + append(CRLF) + } + + /** + * Escapes a TEXT value per RFC 5545 §3.3.11. Backslash first, or it would + * double the backslashes introduced by the later replacements. + */ + internal fun escapeText(value: String): String = value + .replace("\\", "\\\\") + .replace(";", "\\;") + .replace(",", "\\,") + .replace("\r\n", "\\n") + .replace("\n", "\\n") + .replace("\r", "\\n") + + /** + * Folds a content line to at most [MAX_LINE_OCTETS] octets, continuing with + * CRLF + a single space. + * + * Counted in **octets, not characters** — the limit is defined that way, and an + * emoji in a task title is four of them. Splits are kept on character + * boundaries so folding can never cut a UTF-8 sequence in half and corrupt the + * text; an importer unfolds by removing CRLF + leading whitespace, recovering + * the original exactly. + */ + internal fun fold(content: String): String { + if (content.utf8Size() <= MAX_LINE_OCTETS) return content + + val out = StringBuilder() + var octets = 0 + // First line takes the full budget; every continuation loses one octet to + // the leading space. + var budget = MAX_LINE_OCTETS + var index = 0 + while (index < content.length) { + val codePoint = content.codePointAt(index) + val charCount = Character.charCount(codePoint) + val size = String(Character.toChars(codePoint)).utf8Size() + if (octets + size > budget) { + out.append(CRLF).append(' ') + octets = 0 + budget = MAX_LINE_OCTETS - 1 + } + out.append(content, index, index + charCount) + octets += size + index += charCount + } + return out.toString() + } + + private fun String.utf8Size(): Int = toByteArray(Charsets.UTF_8).size + + private fun TaskStatus.toICalName(): String = when (this) { + TaskStatus.NEEDS_ACTION -> "NEEDS-ACTION" + TaskStatus.IN_PROCESS -> "IN-PROCESS" + TaskStatus.COMPLETED -> "COMPLETED" + TaskStatus.CANCELLED -> "CANCELLED" + } + + private const val CRLF = "\r\n" +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpander.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpander.kt new file mode 100644 index 0000000..29f65e4 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpander.kt @@ -0,0 +1,179 @@ +package de.jeanlucmakiola.agendula.domain.recurrence + +import org.dmfs.rfc5545.DateTime +import org.dmfs.rfc5545.recur.RecurrenceRule +import org.dmfs.rfc5545.recurrenceset.RecurrenceList +import org.dmfs.rfc5545.recurrenceset.RecurrenceRuleAdapter +import org.dmfs.rfc5545.recurrenceset.RecurrenceSet +import java.time.ZoneId +import java.util.TimeZone +import kotlin.time.Instant + +private const val MILLIS_PER_SECOND = 1000L +private const val MILLIS_PER_DAY = 24L * 60 * 60 * 1000 + +/** The rule set of one task series, as stored. All strings are raw iCalendar values. */ +data class RecurrenceSpec( + val rrule: String?, + val rdate: String?, + val exdate: String?, + /** The series anchor: DTSTART if present, else DUE. Never null for a recurring task. */ + val anchor: Instant, + val isAllDay: Boolean, + /** IANA zone id the anchor is expressed in; null means floating/local. */ + val timeZone: String?, +) + +/** + * The window expansion is bounded to: [from] inclusive, [until] exclusive, and + * never more than [maxOccurrences] results — so an unbounded `RRULE` terminates. + * + * [pivot] is where "now" sits inside the window, and it is what the occurrence + * budget is spent around. Without it a series firing more often than about + * once a day exhausts [maxOccurrences] inside the past alone — an eight-hourly + * task would stop expanding months before today, so it would never appear in + * Today or Upcoming at all. At most a quarter of the budget goes to occurrences + * before [pivot], and the most recent of those are the ones kept. + */ +data class ExpansionWindow( + val from: Instant, + val until: Instant, + val maxOccurrences: Int = 500, + val pivot: Instant = from, +) + +/** + * Expands a task series into its occurrences in memory, over `lib-recur`. + * + * There is no materialised instances table behind this: the repository already + * filters and sorts in Kotlin, so occurrences are computed at read time and the + * whole class of staleness bugs a cached table brings never exists. + */ +object RecurrenceExpander { + + /** + * Every occurrence of [spec] inside [window], as its `RECURRENCE-ID` anchor — + * the instant identifying that occurrence within the series. Ascending, + * deduplicated, `EXDATE` applied. + * + * The anchor itself is always part of the set (RFC 5545 §3.8.5.3: `DTSTART` + * is the first instance), so a spec with no rule and no `RDATE` expands to + * exactly its anchor. A malformed `RRULE`, `RDATE` or `EXDATE` is dropped + * rather than thrown — a task whose stored rule cannot be parsed still has + * to appear. + * + * [floatingZone] resolves a series with no [RecurrenceSpec.timeZone]; it is a + * parameter rather than a `TimeZone.getDefault()` lookup so expansion is + * deterministic under test. + */ + fun expand( + spec: RecurrenceSpec, + window: ExpansionWindow, + floatingZone: ZoneId = ZoneId.systemDefault(), + ): List { + val zone = zoneOf(spec, floatingZone) + val anchorMillis = anchorMillis(spec) + + val set = RecurrenceSet() + spec.rrule.orNull()?.let { raw -> ruleOf(raw, zone)?.let { set.addInstances(RecurrenceRuleAdapter(it)) } } + spec.rdate.orNull()?.let { raw -> datesOf(raw, zone)?.let(set::addInstances) } + spec.exdate.orNull()?.let { raw -> datesOf(raw, zone)?.let(set::addExceptions) } + + val iterator = set.iterator(zone, anchorMillis, window.until.toEpochMilliseconds()) + iterator.fastForward(window.from.toEpochMilliseconds()) + + // Occurrences arrive ascending, so everything before the pivot lands first + // and `past` is final by the time the first future one appears. Past is a + // sliding window (the newest are the ones worth keeping); the rest of the + // budget then goes to the future, undiminished when there is no past. + val pastCap = window.maxOccurrences / 4 + val past = ArrayDeque() + val future = ArrayList() + var previous = Long.MIN_VALUE + while (iterator.hasNext()) { + val millis = iterator.next() + if (millis == previous) continue + previous = millis + val at = Instant.fromEpochMilliseconds(millis) + if (at < window.pivot) { + if (past.size == pastCap) past.removeFirst() + if (pastCap > 0) past.addLast(at) + } else { + future += at + if (past.size + future.size >= window.maxOccurrences) break + } + } + return past + future + } + + /** + * Index of the current occurrence in an ascending [occurrences] list: the + * first one at or after [now], or the last one when the whole series is in + * the past. `-1` when there are no occurrences at all. + */ + fun currentOccurrenceIndex(occurrences: List, now: Instant): Int { + if (occurrences.isEmpty()) return -1 + val next = occurrences.indexOfFirst { it >= now } + return if (next >= 0) next else occurrences.lastIndex + } + + /** + * Each occurrence's distance from the current one, index-aligned with + * [occurrences]. `0` is the current occurrence, negative counts back into the + * past and positive counts forward — the convention `Task.distanceFromCurrent` + * carries and the data sources pick the current occurrence by. + * + * Purely positional: unlike the dmfs provider, which drove the same number off + * each instance's closed state, this knows only times. Completion-aware + * refinement belongs where overrides carry their status. + */ + fun distancesFromCurrent(occurrences: List, now: Instant): List { + val current = currentOccurrenceIndex(occurrences, now) + if (current < 0) return emptyList() + return occurrences.indices.map { it - current } + } + + private fun zoneOf(spec: RecurrenceSpec, floatingZone: ZoneId): TimeZone { + if (spec.isAllDay) return TimeZone.getTimeZone(ZoneId.of("UTC")) + val stored = spec.timeZone?.let { runCatching { ZoneId.of(it) }.getOrNull() } + return TimeZone.getTimeZone(stored ?: floatingZone) + } + + /** + * All-day series are date-anchored: pin the anchor to UTC midnight, as it is + * stored. A timed one is floored to the second, because RFC 5545 DATE-TIME + * has no sub-second field — carrying millis in makes lib-recur emit the raw + * anchor *and* its truncated self, doubling the first occurrence, and mints + * `RECURRENCE-ID`s no other client could address. + */ + private fun anchorMillis(spec: RecurrenceSpec): Long { + val millis = spec.anchor.toEpochMilliseconds() + val unit = if (spec.isAllDay) MILLIS_PER_DAY else MILLIS_PER_SECOND + return Math.floorDiv(millis, unit) * unit + } + + private fun ruleOf(value: String, zone: TimeZone): RecurrenceRule? = runCatching { + RecurrenceRule(value).also { rule -> + // lib-recur refuses to iterate a floating UNTIL against a zoned start, + // and RFC 5545 §3.3.10 forbids that pairing — but stored rules carry it + // anyway. Re-read the UNTIL's local fields in the series zone. + val until = rule.until + if (until != null && until.isFloating) { + rule.until = DateTime( + zone, + until.year, + until.month, + until.dayOfMonth, + until.hours, + until.minutes, + until.seconds, + ) + } + } + }.getOrNull() + + private fun datesOf(value: String, zone: TimeZone): RecurrenceList? = + runCatching { RecurrenceList(value, zone) }.getOrNull() + + private fun String?.orNull(): String? = this?.trim()?.ifEmpty { null } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/RootScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/RootScreen.kt index e481bd7..3154095 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/RootScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/RootScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -19,6 +20,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.ui.common.OnResume import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus import de.jeanlucmakiola.agendula.ui.navigation.AgendulaNavHost import de.jeanlucmakiola.agendula.ui.permission.PermissionViewModel @@ -39,11 +41,23 @@ fun RootScreen( ActivityResultContracts.RequestMultiplePermissions(), ) { permissionViewModel.refresh() } + // Re-check on every resume, not just after the in-app request: the user may + // have granted the permission (or installed a provider) in system Settings and + // come back, and otherwise the gate would hold until the process restarts. + OnResume { permissionViewModel.refresh() } + + // Neither gate can show in OWN mode (that store is always READY), so the way + // out of one is always our own store. Without it a user whose provider app + // went away is held on this screen with Settings behind it. + val fallback = stringResource(R.string.onboarding_use_own_store) + when (permission.status) { ProviderStatus.NO_PROVIDER -> Gate( modifier = modifier, title = stringResource(R.string.onboarding_no_provider_title), body = stringResource(R.string.onboarding_no_provider_body), + secondaryAction = fallback, + onSecondaryAction = permissionViewModel::useOwnStore, ) ProviderStatus.NEEDS_PERMISSION -> Gate( modifier = modifier, @@ -51,6 +65,8 @@ fun RootScreen( body = stringResource(R.string.onboarding_permission_body), action = stringResource(R.string.onboarding_permission_button), onAction = { launcher.launch(permission.permissionsToRequest.toTypedArray()) }, + secondaryAction = fallback, + onSecondaryAction = permissionViewModel::useOwnStore, ) ProviderStatus.READY -> ReadyGate(modifier = modifier) } @@ -87,6 +103,8 @@ private fun Gate( modifier: Modifier = Modifier, action: String? = null, onAction: () -> Unit = {}, + secondaryAction: String? = null, + onSecondaryAction: () -> Unit = {}, ) { Column( modifier = modifier.fillMaxSize().padding(24.dp), @@ -96,5 +114,6 @@ private fun Gate( Text(title, style = MaterialTheme.typography.headlineSmall) Text(body, style = MaterialTheme.typography.bodyMedium) if (action != null) Button(onClick = onAction) { Text(action) } + if (secondaryAction != null) TextButton(onClick = onSecondaryAction) { Text(secondaryAction) } } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/DateTimeField.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/DateTimeField.kt deleted file mode 100644 index 493f41f..0000000 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/DateTimeField.kt +++ /dev/null @@ -1,167 +0,0 @@ -package de.jeanlucmakiola.agendula.ui.common - -import de.jeanlucmakiola.floret.time.formatDateTime -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Clear -import androidx.compose.material.icons.rounded.Event -import androidx.compose.material3.DatePicker -import androidx.compose.material3.DatePickerDialog -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TimePicker -import androidx.compose.material3.rememberDatePickerState -import androidx.compose.material3.rememberTimePickerState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -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 -import androidx.compose.ui.window.Dialog -import de.jeanlucmakiola.agendula.R -import java.time.LocalDate -import java.time.LocalTime -import java.time.ZoneId -import java.time.ZoneOffset -import kotlin.time.Instant - -private val zone: ZoneId get() = ZoneId.systemDefault() - -internal fun Instant.toLocalDate(): LocalDate = - java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalDate() - -internal fun Instant.toLocalTime(): LocalTime = - java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalTime() - -internal fun localToInstant(date: LocalDate, time: LocalTime): Instant = - Instant.fromEpochMilliseconds(date.atTime(time).atZone(zone).toInstant().toEpochMilli()) - -/** - * A labelled date(-time) field for the edit form: a tonal row showing the - * current value (or nothing), tappable to pick a date and — unless [allDay] — - * a time. A clear affordance appears once a value is set. Emits `null` when - * cleared. Styled to match the app's rounded tonal family. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun DateTimeField( - label: String, - value: Instant?, - allDay: Boolean, - onChange: (Instant?) -> Unit, - modifier: Modifier = Modifier, -) { - var showDatePicker by remember { mutableStateOf(false) } - var showTimePicker by remember { mutableStateOf(false) } - var pendingDate by remember { mutableStateOf(null) } - - Surface( - onClick = { showDatePicker = true }, - shape = RoundedCornerShape(22.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - modifier = modifier.fillMaxWidth(), - ) { - Row( - modifier = Modifier.padding(horizontal = 20.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Icon(Icons.Rounded.Event, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) - Column(modifier = Modifier.weight(1f)) { - Text( - text = label, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = value?.formatDateTime(allDay) ?: stringResource(R.string.edit_set), - style = MaterialTheme.typography.bodyLarge, - ) - } - if (value != null) { - IconButton(onClick = { onChange(null) }) { - Icon(Icons.Rounded.Clear, contentDescription = stringResource(R.string.edit_clear)) - } - } - } - } - - if (showDatePicker) { - val initialMillis = (value ?: Instant.fromEpochMilliseconds(System.currentTimeMillis())) - .toLocalDate().atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() - val dateState = rememberDatePickerState(initialSelectedDateMillis = initialMillis) - DatePickerDialog( - onDismissRequest = { showDatePicker = false }, - confirmButton = { - TextButton( - onClick = { - showDatePicker = false - val millis = dateState.selectedDateMillis ?: return@TextButton - val date = java.time.Instant.ofEpochMilli(millis) - .atZone(ZoneOffset.UTC).toLocalDate() - if (allDay) { - onChange(localToInstant(date, LocalTime.MIDNIGHT)) - } else { - pendingDate = date - showTimePicker = true - } - }, - ) { Text(stringResource(android.R.string.ok)) } - }, - dismissButton = { - TextButton(onClick = { showDatePicker = false }) { - Text(stringResource(android.R.string.cancel)) - } - }, - ) { DatePicker(state = dateState) } - } - - if (showTimePicker) { - val base = value ?: Instant.fromEpochMilliseconds(System.currentTimeMillis()) - val timeState = rememberTimePickerState( - initialHour = base.toLocalTime().hour, - initialMinute = base.toLocalTime().minute, - ) - Dialog(onDismissRequest = { showTimePicker = false }) { - Surface( - shape = RoundedCornerShape(28.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - ) { - Column( - modifier = Modifier.padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - TimePicker(state = timeState) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - TextButton(onClick = { showTimePicker = false }) { - Text(stringResource(android.R.string.cancel)) - } - TextButton(onClick = { - showTimePicker = false - val date = pendingDate ?: return@TextButton - onChange(localToInstant(date, LocalTime.of(timeState.hour, timeState.minute))) - }) { Text(stringResource(android.R.string.ok)) } - } - } - } - } - } -} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ListColors.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ListColors.kt new file mode 100644 index 0000000..0ccf9fd --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ListColors.kt @@ -0,0 +1,29 @@ +package de.jeanlucmakiola.agendula.ui.common + +/** + * The colours offered when creating or editing a task list. + * + * Raw ARGB, the way a CalDAV server sends one — every surface that draws a list + * colour runs it through + * [de.jeanlucmakiola.floret.components.pastelize] first, so these are hues + * rather than final fills, chosen to stay distinguishable after that pass. A + * list can still carry any colour a server gives it; this is only the set the + * app hands out. + */ +val ListPalette: List = listOf( + 0xFF7A5C6B.toInt(), // mauve — Agendula's own seed + 0xFFD7484A.toInt(), // red + 0xFFE8743B.toInt(), // orange + 0xFFE0A32E.toInt(), // amber + 0xFF7CA83E.toInt(), // olive + 0xFF35A06A.toInt(), // green + 0xFF19938C.toInt(), // teal + 0xFF2A9BC4.toInt(), // cyan + 0xFF3C74C8.toInt(), // blue + 0xFF6A5CC0.toInt(), // indigo + 0xFF9455B8.toInt(), // purple + 0xFFC94F8E.toInt(), // pink +) + +/** What a new list gets before the user picks anything. */ +val DefaultListColor: Int = ListPalette.first() diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/OnResume.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/OnResume.kt new file mode 100644 index 0000000..b29f473 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/OnResume.kt @@ -0,0 +1,29 @@ +package de.jeanlucmakiola.agendula.ui.common + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner + +/** + * Runs [block] on every `ON_RESUME`. + * + * For state the app cannot observe because it is granted, revoked or installed + * outside it — a runtime permission, an exact-alarm allowance, a provider app — + * which otherwise stays stale until the process restarts. + */ +@Composable +fun OnResume(block: () -> Unit) { + val current by rememberUpdatedState(block) + val lifecycle = LocalLifecycleOwner.current.lifecycle + DisposableEffect(lifecycle) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) current() + } + lifecycle.addObserver(observer) + onDispose { lifecycle.removeObserver(observer) } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/PickerTime.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/PickerTime.kt new file mode 100644 index 0000000..be77de3 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/PickerTime.kt @@ -0,0 +1,20 @@ +package de.jeanlucmakiola.agendula.ui.common + +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId +import kotlin.time.Instant + +/** + * Zone helpers shared by the date/time pickers. All-day conversions live in + * [de.jeanlucmakiola.agendula.domain.AllDayTime] — these cover the timed case, + * where the device zone is the right frame of reference. + */ + +private val zone: ZoneId get() = ZoneId.systemDefault() + +internal fun Instant.toLocalTime(): LocalTime = + java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalTime() + +internal fun localToInstant(date: LocalDate, time: LocalTime): Instant = + Instant.fromEpochMilliseconds(date.atTime(time).atZone(zone).toInstant().toEpochMilli()) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ShapedActionButton.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ShapedActionButton.kt index f7ddf99..6efdaba 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ShapedActionButton.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ShapedActionButton.kt @@ -98,4 +98,7 @@ object ActionShapes { /** Search — a 6-sided cookie, the same family as [Settings] but distinct. */ val Search: RoundedPolygon get() = MaterialShapes.Cookie6Sided + + /** New list — a sunny burst beside the Lists header. */ + val AddList: RoundedPolygon get() = MaterialShapes.Sunny } 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 2e48a3a..fff9af0 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 @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import de.jeanlucmakiola.agendula.data.tasks.TasksRepository +import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskDetail import de.jeanlucmakiola.agendula.domain.TaskForm @@ -11,7 +12,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map @@ -42,14 +42,14 @@ class TaskDetailViewModel @Inject constructor( if (detail == null) TaskDetailUiState.NotFound else TaskDetailUiState.Content(detail) } .onStart { emit(TaskDetailUiState.Loading) } - .catch { emit(TaskDetailUiState.NotFound) } + .recoveringFromProviderFailure { TaskDetailUiState.NotFound } } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskDetailUiState.Loading) fun bind(id: Long) { taskId.value = id } fun toggleComplete(task: Task) = viewModelScope.launch { - runCatching { repository.setCompleted(task.taskId, !task.isCompleted) } + runCatching { repository.setCompleted(task.taskId, task.occurrenceStart, !task.isCompleted) } } fun delete(task: Task) = viewModelScope.launch { 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 03e4e4e..570ca19 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 @@ -99,7 +99,8 @@ import de.jeanlucmakiola.floret.time.formatTime import de.jeanlucmakiola.agendula.ui.common.localToInstant import de.jeanlucmakiola.floret.components.pastelize import de.jeanlucmakiola.floret.components.positionOf -import de.jeanlucmakiola.agendula.ui.common.toLocalDate +import de.jeanlucmakiola.agendula.domain.allDayInstantOf +import de.jeanlucmakiola.agendula.domain.calendarDate import de.jeanlucmakiola.agendula.ui.common.toLocalTime import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel import java.time.LocalTime @@ -178,7 +179,7 @@ private fun EditContent( val accent = selectedList?.let { pastelize(it.color, dark) } ?: MaterialTheme.colorScheme.primary val gap = 12.dp - var pickerTarget by remember { mutableStateOf(null) } + var pickerTarget by rememberSaveable { mutableStateOf(null) } var showListPicker by rememberSaveable { mutableStateOf(false) } var showParentPicker by rememberSaveable { mutableStateOf(false) } var showReminderPicker by rememberSaveable { mutableStateOf(false) } @@ -689,12 +690,15 @@ private fun DateTimePickerFlow( onResult: (Instant) -> Unit, onDismiss: () -> Unit, ) { - var pendingDate by remember { mutableStateOf(null) } - var showTime by remember { mutableStateOf(false) } + var pendingDate by rememberSaveable { mutableStateOf(null) } + var showTime by rememberSaveable { mutableStateOf(false) } if (!showTime) { + // M3's DatePicker speaks UTC millis. An all-day value is already UTC-based, + // a timed one is read in the device zone — calendarDate picks the right frame + // so the dialog opens on the day the rest of the UI shows. val initialMillis = (initial ?: nowInstant()) - .toLocalDate().atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() + .calendarDate(allDay).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() val dateState = rememberDatePickerState(initialSelectedDateMillis = initialMillis) DatePickerDialog( onDismissRequest = onDismiss, @@ -703,7 +707,7 @@ private fun DateTimePickerFlow( val millis = dateState.selectedDateMillis ?: run { onDismiss(); return@TextButton } val date = java.time.Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC).toLocalDate() if (allDay) { - onResult(localToInstant(date, LocalTime.MIDNIGHT)) + onResult(allDayInstantOf(date)) } else { pendingDate = date showTime = true 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 d25d0e5..03d5e63 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 @@ -15,6 +15,7 @@ import de.jeanlucmakiola.agendula.domain.TaskFormError import de.jeanlucmakiola.agendula.domain.TaskFormField import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.agendula.domain.populatedFields +import de.jeanlucmakiola.agendula.domain.rebasedForAllDay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -70,6 +71,15 @@ class TaskEditViewModel @Inject constructor( private var editingTaskId: Long? = null + /** + * Whether the form has already been populated. The host `LaunchedEffect` + * re-fires whenever the composition restarts — an Activity recreation + * (rotation, theme/font/display-size change, split-screen, unfolding) — while + * this ViewModel survives on the nav back stack. Without this guard the + * rebind would overwrite in-progress edits with the untouched provider row. + */ + private var bound = false + /** `last_modified` captured when the form loaded — the conflict-check baseline. */ private var baselineLastModified: Instant? = null @@ -78,6 +88,8 @@ class TaskEditViewModel @Inject constructor( /** Start a fresh task, optionally pre-selecting a list / parent. */ fun bindNew(presetListId: Long? = null, parentId: Long? = null) { + if (bound) return + bound = true editingTaskId = null baselineLastModified = null viewModelScope.launch { @@ -103,6 +115,8 @@ class TaskEditViewModel @Inject constructor( /** Load an existing task for editing. */ fun bindEdit(taskId: Long) { + if (bound && editingTaskId == taskId) return + bound = true editingTaskId = taskId viewModelScope.launch { defaultFields = settingsPrefs.settings.first().defaultEditFields @@ -126,6 +140,7 @@ class TaskEditViewModel @Inject constructor( priority = task.priority, parentId = task.parentId, percentComplete = task.percentComplete, + reminderMinutesBeforeDue = repository.reminderFor(taskId), lists = lists, parentCandidates = loadParents(task.listId, selfId = taskId), ), @@ -178,7 +193,19 @@ class TaskEditViewModel @Inject constructor( fun onStartChange(value: Instant?) = update { it.copy(start = value) } fun onDueChange(value: Instant?) = update { it.copy(due = value) } - fun onAllDayChange(value: Boolean) = update { it.copy(isAllDay = value) } + /** + * 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. + */ + fun onAllDayChange(value: Boolean) = update { + it.copy( + isAllDay = value, + start = it.start?.rebasedForAllDay(value), + due = it.due?.rebasedForAllDay(value), + ) + } 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) } 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 new file mode 100644 index 0000000..c9ae0f1 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/export/ExportScreen.kt @@ -0,0 +1,182 @@ +package de.jeanlucmakiola.agendula.ui.export + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Circle +import androidx.compose.material.icons.rounded.Folder +import androidx.compose.material.icons.rounded.FolderZip +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.data.export.ExportFailure +import de.jeanlucmakiola.floret.components.CollapsingScaffold +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.pastelize +import de.jeanlucmakiola.floret.components.positionOf + +private const val ZIP_MIME = "application/zip" +private const val ZIP_NAME = "agendula-tasks.zip" + +/** + * Export the task lists as iCalendar. Which lists go out is a per-list tick; the + * destination is a folder or a single zip, both picked through SAF so the app + * needs no storage permission. + */ +@Composable +fun ExportScreen( + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: ExportViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val dark = isSystemInDarkTheme() + + val folderLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocumentTree(), + ) { uri -> uri?.let(viewModel::exportToFolder) } + val zipLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument(ZIP_MIME), + ) { uri -> uri?.let(viewModel::exportToZip) } + + val canExport = !state.running && state.selectedCount > 0 + + CollapsingScaffold( + title = stringResource(R.string.settings_export), + onBack = onBack, + modifier = modifier, + ) { + Text( + text = stringResource(R.string.export_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + Spacer(Modifier.height(16.dp)) + + if (state.lists.isEmpty()) { + GroupedRow( + title = stringResource(R.string.export_no_lists), + position = Position.Alone, + dimmed = true, + ) + } else { + state.lists.forEachIndexed { index, list -> + val selected = state.isSelected(list.id) + GroupedRow( + title = list.name, + // The account only says something when it isn't the device itself. + summary = list.accountName.takeIf { !list.isLocal }, + position = positionOf(index, state.lists.size), + leading = { + Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark)) + }, + trailing = { + Checkbox(checked = selected, onCheckedChange = { viewModel.toggle(list.id) }) + }, + onClick = { viewModel.toggle(list.id) }, + ) + } + } + + Spacer(Modifier.height(24.dp)) + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = { folderLauncher.launch(null) }, + enabled = canExport, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Rounded.Folder, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.size(8.dp)) + Text(stringResource(R.string.export_to_folder)) + } + OutlinedButton( + onClick = { zipLauncher.launch(ZIP_NAME) }, + enabled = canExport, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Rounded.FolderZip, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.size(8.dp)) + Text(stringResource(R.string.export_to_zip)) + } + } + + Spacer(Modifier.height(16.dp)) + ExportStatus(state = state) + Spacer(Modifier.height(24.dp)) + } +} + +/** The running spinner, then whatever the last export ended as — it stays put. */ +@Composable +private fun ExportStatus(state: ExportUiState) { + val outcome = state.outcome + when { + state.running -> Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + CircularProgressIndicator(Modifier.size(18.dp)) + Text( + text = stringResource(R.string.export_running), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + outcome is ExportOutcome.Success -> StatusText( + text = pluralStringResource(R.plurals.export_done, outcome.fileCount, outcome.fileCount), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + outcome is ExportOutcome.Failure -> StatusText( + text = stringResource(failureMessage(outcome.reason)), + color = MaterialTheme.colorScheme.error, + ) + } +} + +private fun failureMessage(reason: ExportFailure): Int = when (reason) { + ExportFailure.FOLDER_UNAVAILABLE -> R.string.export_failed_folder + ExportFailure.FOLDER_NOT_WRITABLE -> R.string.export_failed_read_only + ExportFailure.CANNOT_CREATE_FILE -> R.string.export_failed_create + ExportFailure.LOST_ACCESS -> R.string.export_failed_access + ExportFailure.WRITE_FAILED -> R.string.export_failed +} + +@Composable +private fun StatusText(text: String, color: Color) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = color, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/export/ExportViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/export/ExportViewModel.kt new file mode 100644 index 0000000..f091b9c --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/export/ExportViewModel.kt @@ -0,0 +1,122 @@ +package de.jeanlucmakiola.agendula.ui.export + +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import de.jeanlucmakiola.agendula.data.export.ExportFailedException +import de.jeanlucmakiola.agendula.data.export.ExportFailure +import de.jeanlucmakiola.agendula.data.export.ExportResult +import de.jeanlucmakiola.agendula.data.export.ExportWriter +import de.jeanlucmakiola.agendula.data.export.TaskExporter +import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver +import de.jeanlucmakiola.agendula.data.tasks.TasksRepository +import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure +import de.jeanlucmakiola.agendula.domain.TaskList +import de.jeanlucmakiola.agendula.domain.export.ExportDocument +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.coroutines.cancellation.CancellationException + +/** How the last export ended, kept on screen rather than flashed past. */ +sealed interface ExportOutcome { + data class Success(val fileCount: Int) : ExportOutcome + data class Failure(val reason: ExportFailure) : ExportOutcome +} + +data class ExportUiState( + val lists: List = emptyList(), + /** Lists the user has ticked *off*; everything else is included. */ + val excluded: Set = emptySet(), + val running: Boolean = false, + val outcome: ExportOutcome? = null, +) { + fun isSelected(listId: Long): Boolean = listId !in excluded + + val selectedCount: Int get() = lists.count { isSelected(it.id) } +} + +/** + * Drives the export screen. Holds the selection as an exclusion set so a list + * that appears while the screen is open is exported too — the natural reading of + * "everything, minus what I unticked". + */ +@HiltViewModel +class ExportViewModel @Inject constructor( + repository: TasksRepository, + resolver: ProviderResolver, + private val exporter: TaskExporter, + private val writer: ExportWriter, +) : ViewModel() { + + private val excluded = MutableStateFlow(emptySet()) + private val running = MutableStateFlow(false) + private val outcome = MutableStateFlow(null) + + private var exportJob: Job? = null + + // List ids are per-store, and Settings can switch stores with this ViewModel + // still alive — so the selection, the receipt and a write already addressing + // the old store's lists all go with it. + private val modeHandle = resolver.onModeChanged { + exportJob?.cancel() + excluded.value = emptySet() + outcome.value = null + } + + override fun onCleared() { + modeHandle.close() + } + + val state: StateFlow = + combine( + repository.taskLists().recoveringFromProviderFailure { emptyList() }, + excluded, + running, + outcome, + ) { lists, excluded, running, outcome -> + ExportUiState(lists, excluded, running, outcome) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ExportUiState()) + + fun toggle(listId: Long) = excluded.update { current -> + if (listId in current) current - listId else current + listId + } + + /** Writes one `.ics` per list into a folder the user picked through SAF. */ + fun exportToFolder(tree: Uri) = export { documents -> writer.writeToTree(tree, documents) } + + /** Writes every list into a single zip the user named through SAF. */ + fun exportToZip(target: Uri) = export { documents -> writer.writeZip(target, documents) } + + private fun export(write: suspend (List) -> ExportResult) { + if (running.value) return + running.value = true + outcome.value = null + exportJob = viewModelScope.launch { + // Null means "every list" to the exporter, and is what an untouched + // screen should send: the flow may not have emitted a list yet. + val selection = excluded.value.takeIf { it.isNotEmpty() } + ?.let { skipped -> state.value.lists.map { it.id }.toSet() - skipped } + try { + val result = write(exporter.export(selection)) + outcome.value = ExportOutcome.Success(result.fileCount) + } catch (cancelled: CancellationException) { + // Leaving the screen mid-write is not a failed export. + throw cancelled + } catch (error: Exception) { + outcome.value = ExportOutcome.Failure( + (error as? ExportFailedException)?.failure ?: ExportFailure.WRITE_FAILED, + ) + } finally { + running.value = false + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListEditorSheet.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListEditorSheet.kt new file mode 100644 index 0000000..7ce564a --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListEditorSheet.kt @@ -0,0 +1,295 @@ +package de.jeanlucmakiola.agendula.ui.lists + +import androidx.compose.foundation.background +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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.DeleteOutline +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.domain.TaskList +import de.jeanlucmakiola.agendula.ui.common.DefaultListColor +import de.jeanlucmakiola.agendula.ui.common.ListColorChip +import de.jeanlucmakiola.agendula.ui.common.ListPalette +import de.jeanlucmakiola.floret.components.FullScreenPicker +import de.jeanlucmakiola.floret.components.GroupedSurface +import de.jeanlucmakiola.floret.components.InlineTextField +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.pastelize + +private const val SWATCHES_PER_ROW = 6 + +/** + * Create or edit a task list: a name field over the palette of list colours, on + * the family's full-screen sheet with the commit in its title bar. + * + * [initial] null is the create case. [onDelete] is null for a list the app must + * not remove — an account's collection belongs to its server — which is also why + * the destructive row only appears when it is non-null. + */ +@Composable +fun ListEditorSheet( + initial: TaskList?, + onSave: (name: String, color: Int) -> Unit, + onDismiss: () -> Unit, + onDelete: (() -> Unit)? = null, +) { + var name by rememberSaveable(initial?.id) { mutableStateOf(initial?.name.orEmpty()) } + var color by rememberSaveable(initial?.id) { mutableIntStateOf(initial?.color ?: DefaultListColor) } + var confirmDelete by rememberSaveable { mutableStateOf(false) } + + val valid = name.isNotBlank() + val commit = { + if (valid) { + onSave(name.trim(), color) + onDismiss() + } + } + + FullScreenPicker( + title = stringResource(if (initial == null) R.string.list_new_title else R.string.list_edit_title), + onDismiss = onDismiss, + actions = { + Button( + onClick = commit, + enabled = valid, + modifier = Modifier.padding(end = 12.dp), + ) { Text(stringResource(R.string.save)) } + }, + ) { + NameField( + name = name, + color = color, + // A new list opens with the keyboard up: naming it is the whole task. + autoFocus = initial == null, + onNameChange = { name = it }, + onImeAction = commit, + ) + + Spacer(Modifier.height(20.dp)) + SectionLabel(stringResource(R.string.list_color)) + ColorGrid(selected = color, onSelect = { color = it }) + + if (onDelete != null) { + Spacer(Modifier.height(24.dp)) + DeleteRow(onClick = { confirmDelete = true }) + } + Spacer(Modifier.height(24.dp)) + } + + if (confirmDelete && onDelete != null) { + DeleteListDialog( + listName = initial?.name.orEmpty(), + onConfirm = { + confirmDelete = false + onDelete() + onDismiss() + }, + onDismiss = { confirmDelete = false }, + ) + } +} + +/** The name, with the chosen colour beside it so the two read as one thing. */ +@Composable +private fun NameField( + name: String, + color: Int, + autoFocus: Boolean, + onNameChange: (String) -> Unit, + onImeAction: () -> Unit, +) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(autoFocus) { if (autoFocus) focusRequester.requestFocus() } + GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) { + Row( + modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + ListColorChip(color) + InlineTextField( + value = name, + onValueChange = onNameChange, + placeholder = stringResource(R.string.list_name_hint), + imeAction = ImeAction.Done, + onImeAction = onImeAction, + modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), + ) + } + } +} + +/** The palette as two rows of round swatches; the chosen one carries a check. */ +@Composable +private fun ColorGrid(selected: Int, onSelect: (Int) -> Unit) { + val dark = isSystemInDarkTheme() + GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + ListPalette.chunked(SWATCHES_PER_ROW).forEach { row -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + row.forEach { swatch -> + Swatch( + color = swatch, + dark = dark, + selected = swatch == selected, + onClick = { onSelect(swatch) }, + modifier = Modifier.weight(1f), + ) + } + // Keeps a short final row's swatches at the same size as a full + // one's rather than stretching them across the width. + repeat(SWATCHES_PER_ROW - row.size) { Spacer(Modifier.weight(1f)) } + } + } + } + } +} + +@Composable +private fun Swatch( + color: Int, + dark: Boolean, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val fill = pastelize(color, dark) + val label = stringResource(colorLabel(color)) + Box( + modifier = modifier + .aspectRatio(1f) + .clip(CircleShape) + .background(fill) + // selectable, not clickable: the swatch carries its chosen state in + // semantics, so the check below is decoration rather than the only cue. + .selectable(selected = selected, role = Role.RadioButton, onClick = onClick) + .semantics { contentDescription = label }, + contentAlignment = Alignment.Center, + ) { + if (selected) { + Icon( + Icons.Rounded.Check, + contentDescription = null, + tint = if (fill.luminance() > 0.5f) Color.Black else Color.White, + modifier = Modifier.size(22.dp), + ) + } + } +} + +@Composable +private fun DeleteRow(onClick: () -> Unit) { + GroupedSurface( + position = Position.Alone, + modifier = Modifier.padding(horizontal = 16.dp), + onClick = onClick, + color = MaterialTheme.colorScheme.errorContainer, + ) { + Row( + modifier = Modifier.fillMaxWidth().heightIn(min = 64.dp).padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon( + Icons.Rounded.DeleteOutline, + contentDescription = null, + tint = MaterialTheme.colorScheme.onErrorContainer, + ) + Text( + text = stringResource(R.string.list_delete), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } + } +} + +@Composable +private fun DeleteListDialog(listName: String, onConfirm: () -> Unit, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.list_delete_confirm_title)) }, + text = { Text(stringResource(R.string.list_delete_confirm_message, listName)) }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) } + }, + ) +} + +@Composable +private fun SectionLabel(text: String) { + Text( + text = text, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 28.dp, end = 28.dp, bottom = 8.dp), + ) +} + +/** Names the palette entries for screen readers; anything else is just "colour". */ +private fun colorLabel(color: Int): Int = when (ListPalette.indexOf(color)) { + 0 -> R.string.list_color_mauve + 1 -> R.string.list_color_red + 2 -> R.string.list_color_orange + 3 -> R.string.list_color_amber + 4 -> R.string.list_color_olive + 5 -> R.string.list_color_green + 6 -> R.string.list_color_teal + 7 -> R.string.list_color_cyan + 8 -> R.string.list_color_blue + 9 -> R.string.list_color_indigo + 10 -> R.string.list_color_purple + 11 -> R.string.list_color_pink + else -> R.string.list_color +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt index 90bdff0..fa5862a 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt @@ -46,6 +46,7 @@ import androidx.compose.material.icons.rounded.Upcoming import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -80,6 +81,10 @@ import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.ui.common.ActionShapes import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.SnackChip +import de.jeanlucmakiola.floret.components.SnackChipHeight +import de.jeanlucmakiola.floret.components.SnackChipMargin +import kotlinx.coroutines.delay import de.jeanlucmakiola.agendula.ui.common.ListColorChip import de.jeanlucmakiola.agendula.ui.common.ShapedActionButton import de.jeanlucmakiola.agendula.ui.common.priorityAccent @@ -108,10 +113,22 @@ fun ListsScreen( val state by viewModel.state.collectAsStateWithLifecycle() var query by rememberSaveable { mutableStateOf("") } var searchActive by rememberSaveable { mutableStateOf(false) } + var newList by rememberSaveable { mutableStateOf(false) } val closeSearch = { query = "" searchActive = false } + // With no lists there is nowhere to put a task, so the primary action becomes + // making one — otherwise a fresh install's FAB opens a form that cannot save. + val noLists = (state as? ListsUiState.Content)?.groups?.isEmpty() == true + // The sheet closes on save, so a refused write has to report itself here. + val writeFailure by viewModel.writeFailure.collectAsStateWithLifecycle() + LaunchedEffect(writeFailure) { + if (writeFailure != null) { + delay(4_000) + viewModel.clearWriteFailure() + } + } // System back closes search before leaving the screen. BackHandler(enabled = searchActive, onBack = closeSearch) @@ -130,9 +147,11 @@ fun ListsScreen( // The FAB would otherwise float over the search results. if (!searchActive) { ExtendedFloatingActionButton( - onClick = onNewTask, + onClick = if (noLists) ({ newList = true }) else onNewTask, icon = { Icon(Icons.Rounded.Add, contentDescription = null) }, - text = { Text(stringResource(R.string.new_task)) }, + text = { + Text(stringResource(if (noLists) R.string.list_add else R.string.new_task)) + }, ) } }, @@ -147,6 +166,7 @@ fun ListsScreen( state = s, onOpenFilter = onOpenFilter, onOpenTask = onOpenTask, + onNewList = { newList = true }, topPadding = 0.dp, bottomPadding = inner.calculateBottomPadding() + 96.dp, ) @@ -166,8 +186,33 @@ fun ListsScreen( } } } + // Anchored beside the FAB, at its height — the same receipt placement + // the task list uses. + Box( + modifier = Modifier + .align(Alignment.BottomStart) + .padding( + start = SnackChipMargin, + bottom = inner.calculateBottomPadding() + SnackChipMargin, + ) + .height(SnackChipHeight), + contentAlignment = Alignment.CenterStart, + ) { + SnackChip( + visible = writeFailure != null, + message = stringResource(R.string.list_save_failed), + ) + } } } + + if (newList) { + ListEditorSheet( + initial = null, + onSave = viewModel::createList, + onDismiss = { newList = false }, + ) + } } @Composable @@ -175,6 +220,7 @@ private fun ListsContent( state: ListsUiState.Content, onOpenFilter: (TaskFilter) -> Unit, onOpenTask: (Long) -> Unit, + onNewList: () -> Unit, topPadding: androidx.compose.ui.unit.Dp, bottomPadding: androidx.compose.ui.unit.Dp, ) { @@ -215,9 +261,21 @@ private fun ListsContent( } if (state.groups.isEmpty()) { - item { CenteredMessage(stringResource(R.string.lists_empty), PaddingValues(top = 24.dp)) } + item { EmptyLists(onNewList = onNewList) } } else { - item { SectionHeader(stringResource(R.string.lists_header)) } + item { + SectionHeader( + text = stringResource(R.string.lists_header), + action = { + ShapedActionButton( + shape = ActionShapes.AddList, + icon = Icons.Rounded.Add, + contentDescription = stringResource(R.string.list_add), + onClick = onNewList, + ) + }, + ) + } state.groups.forEach { group -> item(key = "acct-${group.accountName}") { AccountHeader(group.accountName) } itemsIndexed(group.lists, key = { _, o -> o.list.id }) { index, overview -> @@ -243,6 +301,28 @@ private fun ListsContent( } } +/** No lists at all — a fresh install, where nothing else on this screen works yet. */ +@Composable +private fun EmptyLists(onNewList: () -> Unit) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 32.dp, vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(R.string.lists_empty), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + FilledTonalButton(onClick = onNewList) { + Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.lists_empty_action)) + } + } +} + /** * The home top bar. There is no title — the launcher icon already says which app * this is. Settings is pinned at the right; the search action sits just left of it @@ -419,7 +499,7 @@ private fun SearchResults( } } else { LazyColumn(modifier = Modifier.fillMaxSize()) { - items(results, key = { it.id }) { task -> + items(results, key = { it.occurrenceKey }) { task -> UpcomingRow(task = task, onClick = { onOpenTask(task.taskId) }) } } @@ -655,12 +735,25 @@ private fun SmartCard(count: SmartCount, modifier: Modifier = Modifier, onClick: } @Composable -private fun SectionHeader(text: String) { - Text( - text = text, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 4.dp), - ) +private fun SectionHeader(text: String, action: (@Composable () -> Unit)? = null) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + start = 28.dp, + end = if (action == null) 28.dp else 20.dp, + top = if (action == null) 16.dp else 12.dp, + bottom = 4.dp, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = text, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + ) + action?.invoke() + } } @Composable diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt index a6d5071..0215035 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt @@ -4,21 +4,30 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import de.jeanlucmakiola.agendula.data.tasks.TasksRepository +import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure import de.jeanlucmakiola.floret.time.DayWindow import de.jeanlucmakiola.agendula.domain.SmartList import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskFiltering import de.jeanlucmakiola.agendula.domain.TaskList +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import java.time.ZoneId import javax.inject.Inject import kotlin.time.Clock +/** + * What a list write failed at. The screens turn this into wording; the view + * models stay free of resources. + */ +enum class ListWriteFailure { SAVE, DELETE } + data class ListOverview(val list: TaskList, val openCount: Int) data class AccountGroup(val accountName: String, val lists: List) data class SmartCount(val smart: SmartList, val count: Int) @@ -44,7 +53,7 @@ private const val UPCOMING_PREVIEW = 3 /** The home overview: smart lists with live counts, then user lists by account. */ @HiltViewModel class ListsViewModel @Inject constructor( - repository: TasksRepository, + private val repository: TasksRepository, ) : ViewModel() { val state: StateFlow = @@ -56,7 +65,7 @@ class ListsViewModel @Inject constructor( repository.tasks(TaskFilter.Smart(SmartList.COMPLETED)), ) { lists, openTasks, completedTasks -> buildContent(lists, openTasks, completedTasks) as ListsUiState - }.catch { emit(ListsUiState.Failure) } + }.recoveringFromProviderFailure { ListsUiState.Failure } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListsUiState.Loading) private fun buildContent( @@ -112,4 +121,22 @@ class ListsViewModel @Inject constructor( allTasks = openTasks + completedTasks, ) } + + private val _writeFailure = MutableStateFlow(null) + + /** Set when a list write is refused; the screen shows it and clears it. */ + val writeFailure: StateFlow = _writeFailure.asStateFlow() + + fun clearWriteFailure() { _writeFailure.value = null } + + /** + * Create a device-only list. The lists flow picks it up on the store change; + * a refusal (External mode, a provider that says no) surfaces through + * [writeFailure] rather than vanishing, because the sheet has already closed. + */ + fun createList(name: String, color: Int) = viewModelScope.launch { + if (name.isBlank()) return@launch + runCatching { repository.createLocalList(name.trim(), color) } + .onFailure { _writeFailure.value = ListWriteFailure.SAVE } + } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/permission/PermissionViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/permission/PermissionViewModel.kt index 9e6760a..5c1e89c 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/permission/PermissionViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/permission/PermissionViewModel.kt @@ -1,13 +1,25 @@ package de.jeanlucmakiola.agendula.ui.permission import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus +import de.jeanlucmakiola.agendula.data.tasks.StorageMode import de.jeanlucmakiola.agendula.data.tasks.TasksRepository +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.buffer import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import javax.inject.Inject data class PermissionUiState( @@ -20,23 +32,51 @@ data class PermissionUiState( * Gates app entry: is a tasks provider installed, and do we hold its permissions? * The Composable owns the actual permission-launcher and store intents; this VM * supplies the [status] and the exact permission strings to ask for. + * + * In the default Own mode this gate never appears at all — the store is our own + * Room database, so there is nothing to install and nothing to grant. It exists + * for External mode, which also makes it the only screen an External user can + * reach once their provider app stops answering: hence [useOwnStore]. */ @HiltViewModel class PermissionViewModel @Inject constructor( private val repository: TasksRepository, private val providerResolver: ProviderResolver, + private val prefs: SettingsPrefs, ) : ViewModel() { - private val _state = MutableStateFlow(PermissionUiState()) - val state: StateFlow = _state.asStateFlow() + private val refreshes = MutableStateFlow(0) - init { refresh() } + // Re-evaluated when the *resolver's* mode lands, not when the preference is + // written: anything read in between still answers for the store we just left. + // Conflated, as everywhere else this signal is bridged: only the latest mode + // matters, and a full buffer drops it rather than the ones it supersedes. + private val modeChanges: Flow = callbackFlow { + trySend(Unit) + val handle = providerResolver.onModeChanged { trySend(Unit) } + awaitClose { handle.close() } + }.buffer(Channel.CONFLATED) + + val state: StateFlow = + combine(refreshes, modeChanges) { _, _ -> currentState() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), currentState()) /** Re-read provider + permission state (call after returning from a request). */ - fun refresh() { + fun refresh() = refreshes.update { it + 1 } + + /** + * Leave a store this device can no longer read. The provider app can be + * uninstalled, or its permission revoked, after External was chosen — and the + * gate is then the only screen reachable, Settings included. Our own store + * always reads, so it is the way out. + */ + fun useOwnStore() = viewModelScope.launch { prefs.setStorageMode(StorageMode.OWN) } + + private fun currentState(): PermissionUiState { val provider = providerResolver.resolve() - _state.value = PermissionUiState( + return PermissionUiState( status = repository.providerStatus(), + // Null in OWN mode, where there is no provider and nothing to grant. permissionsToRequest = provider ?.let { listOf(it.readPermission, it.writePermission) } .orEmpty(), 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 712a61d..5c6da54 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 @@ -49,6 +49,7 @@ import androidx.compose.material.icons.rounded.AccountTree import androidx.compose.material.icons.rounded.Circle import androidx.compose.material.icons.rounded.Flag import androidx.compose.material.icons.rounded.Percent +import androidx.compose.material.icons.rounded.Storage import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -56,7 +57,6 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -75,13 +75,11 @@ import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.core.net.toUri import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.agendula.data.prefs.ThemeMode import de.jeanlucmakiola.agendula.domain.TaskFormField +import de.jeanlucmakiola.agendula.ui.export.ExportScreen import de.jeanlucmakiola.floret.components.AboutCard import de.jeanlucmakiola.floret.components.AboutLink import de.jeanlucmakiola.floret.components.CollapsingScaffold @@ -96,10 +94,22 @@ import de.jeanlucmakiola.floret.locale.AppLanguage import de.jeanlucmakiola.floret.identity.expandEnter import de.jeanlucmakiola.floret.reminders.ReminderOverride import de.jeanlucmakiola.floret.reminders.reminderOverrideFor +import de.jeanlucmakiola.agendula.ui.common.OnResume import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel /** The settings sub-screens reached from the hub's category rows. */ -private enum class SettingsSection { Appearance, TaskForm, Reminders } +private enum class SettingsSection { + Appearance, + TaskForm, + Reminders, + Storage, + Export, + ; + + /** Where back goes: Export is opened from Storage, not from the hub. */ + val parent: SettingsSection? + get() = if (this == Export) Storage else null +} /** * Token-based accent for a leading icon chip (container / on-container pair), @@ -125,7 +135,7 @@ fun SettingsScreen( // 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 = null } + BackHandler(enabled = section != null) { section = section?.parent } Box( modifier = modifier @@ -143,6 +153,19 @@ fun SettingsScreen( SlideInSection(visible = section == SettingsSection.Reminders) { RemindersScreen(state = state, viewModel = viewModel, onBack = { section = null }) } + // Storage stays composed under Export, so the deeper screen slides over it. + val storageOpen = section == SettingsSection.Storage || + section?.parent == SettingsSection.Storage + SlideInSection(visible = storageOpen) { + StorageScreen( + viewModel = viewModel, + onOpenExport = { section = SettingsSection.Export }, + onBack = { section = null }, + ) + } + SlideInSection(visible = section == SettingsSection.Export) { + ExportScreen(onBack = { section = SettingsSection.Storage }) + } } } @@ -212,6 +235,13 @@ private fun SettingsHub( leading = { CategoryIcon(Icons.Default.Notifications, ChipAccent.Primary) }, onClick = { onOpenSection(SettingsSection.Reminders) }, ) + GroupedRow( + title = stringResource(R.string.settings_section_storage), + summary = stringResource(R.string.settings_storage_subtitle), + position = Position.Middle, + leading = { CategoryIcon(Icons.Rounded.Storage, ChipAccent.Neutral) }, + onClick = { onOpenSection(SettingsSection.Storage) }, + ) LanguageRow(position = Position.Middle) ReportProblemRow(position = Position.Bottom) @@ -670,15 +700,10 @@ private fun rememberExactAlarmAllowed(context: Context): Boolean { context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms(), ) } - val lifecycle = LocalLifecycleOwner.current.lifecycle - DisposableEffect(lifecycle) { - val obs = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - allowed = context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms() - } + OnResume { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + allowed = context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms() } - lifecycle.addObserver(obs) - onDispose { lifecycle.removeObserver(obs) } } return allowed } 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 aa2fb24..b607be0 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt @@ -3,18 +3,27 @@ package de.jeanlucmakiola.agendula.ui.settings import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import de.jeanlucmakiola.agendula.data.di.IoDispatcher import de.jeanlucmakiola.agendula.data.prefs.Settings import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.prefs.ThemeMode +import de.jeanlucmakiola.agendula.data.tasks.ProviderEnvironment +import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver +import de.jeanlucmakiola.agendula.data.tasks.StorageMode +import de.jeanlucmakiola.agendula.data.tasks.TaskProvider import de.jeanlucmakiola.agendula.data.tasks.TasksRepository +import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure import de.jeanlucmakiola.agendula.domain.TaskFormField import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.floret.reminders.ReminderOverride +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @@ -23,6 +32,22 @@ data class SettingsUiState( val lists: List = emptyList(), ) +/** + * The storage half of Settings: which store is active, and what picking the + * other one would mean on this device. + * + * Kept apart from [SettingsUiState] because that one is collected for the whole + * Activity lifetime to drive the theme, and re-probing PackageManager on every + * theme emission would be work for nothing. + */ +data class StorageUiState( + val mode: StorageMode, + /** The external provider installed here, or null when there is none to pick. */ + val external: TaskProvider? = null, + /** That provider's own app name, for a row that names what it is switching to. */ + val externalLabel: String? = null, +) + /** * Drives both the Settings screen and the app theme (MainActivity collects the * same instance), so a theme change applies app-wide at once. @@ -30,14 +55,48 @@ data class SettingsUiState( @HiltViewModel class SettingsViewModel @Inject constructor( private val prefs: SettingsPrefs, + private val resolver: ProviderResolver, + private val environment: ProviderEnvironment, + @IoDispatcher io: CoroutineDispatcher, repository: TasksRepository, ) : ViewModel() { + // MainActivity collects this for the theme, above the permission gate and for + // the whole Activity lifetime — so the list flow must survive the pre-grant + // SecurityException and recover once permission is given, not die for good. val state: StateFlow = - combine(prefs.settings, repository.taskLists().catch { emit(emptyList()) }) { settings, lists -> + combine( + prefs.settings, + repository.taskLists().recoveringFromProviderFailure { emptyList() }, + ) { settings, lists -> SettingsUiState(settings, lists) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState()) + // Bumped to re-probe the device; installing a provider or granting its + // permission happens outside the app, so nothing else would emit. + private val providerProbe = MutableStateFlow(0) + + // Null until the first emission lands: the mode comes from DataStore and the + // rest from PackageManager, off the main thread, so any seeded default would + // name the wrong store for the first frames. flowOn, because every field here + // costs a PackageManager lookup or a permission check. + val storage: StateFlow = + combine(prefs.storageMode, providerProbe) { stored, _ -> + val external = resolver.resolveExternal() + StorageUiState( + // No stored choice is the normal state; show what autoMode resolves + // to rather than a default that may not be the store in use. + mode = stored ?: resolver.autoMode(), + external = external, + externalLabel = external?.packageName?.let(environment::appLabel), + ) + }.flowOn(io).stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + + /** Re-read the device's provider state, after a permission request or a resume. */ + fun refreshStorage() = providerProbe.update { it + 1 } + + fun setStorageMode(mode: StorageMode) = viewModelScope.launch { prefs.setStorageMode(mode) } + fun setThemeMode(mode: ThemeMode) = viewModelScope.launch { prefs.setThemeMode(mode) } fun setDynamicColor(enabled: Boolean) = viewModelScope.launch { prefs.setDynamicColor(enabled) } fun setDefaultList(id: Long?) = viewModelScope.launch { prefs.setDefaultListId(id) } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/StorageScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/StorageScreen.kt new file mode 100644 index 0000000..bc7c494 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/StorageScreen.kt @@ -0,0 +1,203 @@ +package de.jeanlucmakiola.agendula.ui.settings + +import android.content.Context +import android.content.Intent +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Apps +import androidx.compose.material.icons.rounded.PhoneAndroid +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.data.tasks.StorageMode +import de.jeanlucmakiola.agendula.data.tasks.TaskProvider +import de.jeanlucmakiola.agendula.ui.common.OnResume +import de.jeanlucmakiola.floret.components.CollapsingScaffold +import de.jeanlucmakiola.floret.components.FullScreenPicker +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.SelectedCheck + +/** + * Where the tasks live: the store picker the resolver's `autoMode()` has always + * assumed, plus the way out of a store that lives in our own private storage. + */ +@Composable +internal fun StorageScreen( + viewModel: SettingsViewModel, + onOpenExport: () -> Unit, + onBack: () -> Unit, +) { + val context = LocalContext.current + val storage by viewModel.storage.collectAsStateWithLifecycle() + var showPicker by remember { mutableStateOf(false) } + var denied by remember { mutableStateOf(false) } + + // The mode is committed only once the grant is in — switching first drops the + // user on the app-wide permission gate. + val permissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestMultiplePermissions(), + ) { grants -> + viewModel.refreshStorage() + val granted = grants.isNotEmpty() && grants.values.all { it } + denied = !granted + if (granted) viewModel.setStorageMode(StorageMode.EXTERNAL) + } + + // A provider can be installed, or its permission revoked, while we're away. + // Deliberately does not clear [denied]: this fires on returning from the + // 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) { + GroupedRow( + title = stringResource(R.string.settings_task_store), + summary = storage?.let { storeLabel(it) }, + position = Position.Top, + // 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 { + { + denied = false + showPicker = true + } + }, + ) + GroupedRow( + title = stringResource(R.string.settings_export), + summary = stringResource(R.string.settings_export_hint), + position = Position.Bottom, + onClick = onOpenExport, + ) + + if (denied) { + Spacer(Modifier.height(16.dp)) + GroupedRow( + title = stringResource(R.string.settings_store_permission_denied), + summary = stringResource(R.string.settings_store_permission_denied_hint), + position = Position.Alone, + onClick = { context.openAppSettings() }, + ) + } + } + + storage?.let { state -> + if (showPicker) { + StorePicker( + storage = state, + onSelect = { mode -> + val external = state.external + if (mode == StorageMode.EXTERNAL && external != null) { + // Asked even when the grant looks held: an already-granted + // request returns at once, a stale belief would strand them. + permissionLauncher.launch( + arrayOf(external.readPermission, external.writePermission), + ) + } else { + viewModel.setStorageMode(mode) + } + }, + onDismiss = { showPicker = false }, + ) + } + } +} + +/** + * The two stores, as rows. External is offered only when a provider is actually + * installed — dimmed and inert otherwise, because a mode with nothing behind it + * empties the app. + */ +@Composable +private fun StorePicker( + storage: StorageUiState, + onSelect: (StorageMode) -> Unit, + onDismiss: () -> Unit, +) { + FullScreenPicker(title = stringResource(R.string.settings_task_store), onDismiss = onDismiss) { + Text( + text = stringResource(R.string.settings_task_store_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + Spacer(Modifier.height(8.dp)) + + val select: (StorageMode) -> Unit = { chosen -> + onSelect(chosen) + onDismiss() + } + val external = storage.external + GroupedRow( + title = stringResource(R.string.settings_store_own), + summary = stringResource(R.string.settings_store_own_hint), + position = Position.Top, + selected = storage.mode == StorageMode.OWN, + leading = { Icon(Icons.Rounded.PhoneAndroid, contentDescription = null) }, + trailing = if (storage.mode == StorageMode.OWN) { + { SelectedCheck() } + } else { + null + }, + onClick = { select(StorageMode.OWN) }, + ) + GroupedRow( + title = externalTitle(external, storage.externalLabel), + summary = stringResource( + if (external == null) { + R.string.settings_store_external_missing + } else { + R.string.settings_store_external_hint + }, + ), + position = Position.Bottom, + selected = storage.mode == StorageMode.EXTERNAL, + dimmed = external == null, + leading = { Icon(Icons.Rounded.Apps, contentDescription = null) }, + trailing = if (storage.mode == StorageMode.EXTERNAL) { + { SelectedCheck() } + } else { + null + }, + onClick = if (external != null) ({ select(StorageMode.EXTERNAL) }) else null, + ) + Spacer(Modifier.height(24.dp)) + } +} + +/** The active store, named the way the picker names it. */ +@Composable +private fun storeLabel(storage: StorageUiState): String = when (storage.mode) { + StorageMode.OWN -> stringResource(R.string.settings_store_own) + StorageMode.EXTERNAL -> externalTitle(storage.external, storage.externalLabel) +} + +/** The provider's own app name, its authority, or the generic wording. */ +@Composable +private fun externalTitle(provider: TaskProvider?, label: String?): String = + label ?: provider?.authority ?: stringResource(R.string.settings_store_external) + +private fun Context.openAppSettings() { + runCatching { + startActivity( + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, "package:$packageName".toUri()), + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt index c4c6a0b..5bcca82 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt @@ -44,6 +44,7 @@ import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Checklist import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.ExpandMore import androidx.compose.material.icons.rounded.Flag import androidx.compose.material3.Checkbox @@ -97,6 +98,8 @@ import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskSection import de.jeanlucmakiola.agendula.domain.TaskSections import de.jeanlucmakiola.agendula.ui.common.priorityAccent +import de.jeanlucmakiola.agendula.ui.lists.ListEditorSheet +import de.jeanlucmakiola.agendula.ui.lists.ListWriteFailure import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.SnackChip import de.jeanlucmakiola.floret.components.SnackChipHeight @@ -126,8 +129,22 @@ fun TaskListScreen( val state by viewModel.state.collectAsStateWithLifecycle() val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() val content = state as? TaskListUiState.Content - val listName = content?.listName + val list = content?.list + val listName = list?.name val listId = (filter as? TaskFilter.OfList)?.listId + // Editing is offered for a device-only list. A collection that belongs to an + // account is the server's to rename or remove, not ours. + var editingList by rememberSaveable { mutableStateOf(false) } + val listWriteFailure by viewModel.listWriteFailure.collectAsStateWithLifecycle() + val listDeleted by viewModel.listDeleted.collectAsStateWithLifecycle() + // The list this screen is about is gone; there is nothing left to show. + LaunchedEffect(listDeleted) { if (listDeleted) onBack() } + LaunchedEffect(listWriteFailure) { + if (listWriteFailure != null) { + delay(4_000) + viewModel.clearListWriteFailure() + } + } // One add affordance, never two: a real list with the setting on gets a pinned // bottom quick-add bar; everything else (incl. smart lists, which have no single // target list) gets the floating "New task" button. @@ -162,6 +179,16 @@ fun TaskListScreen( ) } }, + actions = { + if (list != null && list.isLocal) { + IconButton(onClick = { editingList = true }) { + Icon( + Icons.Rounded.Edit, + contentDescription = stringResource(R.string.list_edit_title), + ) + } + } + }, scrollBehavior = scrollBehavior, ) }, @@ -199,18 +226,43 @@ fun TaskListScreen( .height(SnackChipHeight), contentAlignment = Alignment.CenterStart, ) { - SnackChip( - visible = undoTarget != null, - message = stringResource(R.string.task_deleted), - actionLabel = stringResource(R.string.undo), - onAction = { - undoTarget?.let { viewModel.undoDelete(it.taskId) } - undoTarget = null - }, - ) + // One chip, one anchor: the undo receipt takes precedence, and a + // refused list write reports itself once the undo window is clear. + val failure = listWriteFailure + if (undoTarget != null || failure == null) { + SnackChip( + visible = undoTarget != null, + message = stringResource(R.string.task_deleted), + actionLabel = stringResource(R.string.undo), + onAction = { + undoTarget?.let { viewModel.undoDelete(it.taskId) } + undoTarget = null + }, + ) + } else { + SnackChip( + visible = true, + message = stringResource(listWriteFailureMessage(failure)), + ) + } } } } + + if (editingList && list != null) { + ListEditorSheet( + initial = list, + onSave = { name, color -> viewModel.updateList(list.id, name, color) }, + onDismiss = { editingList = false }, + onDelete = { viewModel.deleteList(list.id) }, + ) + } +} + +/** Wording for a refused list write. */ +private fun listWriteFailureMessage(failure: ListWriteFailure): Int = when (failure) { + ListWriteFailure.SAVE -> R.string.list_save_failed + ListWriteFailure.DELETE -> R.string.list_delete_failed } @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @@ -773,20 +825,20 @@ private fun SubtaskExpandButton(expanded: Boolean, onToggle: () -> Unit) { /** A visual row in a flattened section run: a top-level task, or one of its subtasks. */ private sealed interface ListRow { - val key: Long + val key: String data class Parent(val task: Task, val expandable: Boolean, val expanded: Boolean) : ListRow { - override val key: Long get() = task.taskId + override val key: String get() = task.occurrenceKey } data class Sub(val task: Task) : ListRow { - override val key: Long get() = task.taskId + override val key: String get() = task.occurrenceKey } /** The inline "add a subtask" row that closes an expanded group. */ data class AddSub(val parent: Task) : ListRow { - // Negative so it never collides with a real (positive) provider task id. - override val key: Long get() = -parent.taskId + // Prefixed so it never collides with the task row it belongs to. + override val key: String get() = "add-${parent.occurrenceKey}" } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt index 3120e14..3729555 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt @@ -5,14 +5,17 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.tasks.TasksRepository +import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskForm +import de.jeanlucmakiola.agendula.domain.TaskList +import de.jeanlucmakiola.agendula.ui.lists.ListWriteFailure import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest @@ -28,13 +31,13 @@ sealed interface TaskListUiState { data object Failure : TaskListUiState /** - * [listName] is the real list's name when the filter is a - * [TaskFilter.OfList] (for the top-bar title), `null` for smart lists — - * the screen falls back to the smart label in that case. + * [list] is the real list when the filter is a [TaskFilter.OfList] — it + * titles the bar and backs the edit action — and `null` for smart lists, + * where the screen falls back to the smart label. */ data class Content( val tasks: List, - val listName: String? = null, + val list: TaskList? = null, /** Whether the inline "add a subtask" row shows on expanded groups (M5 setting). */ val showAddSubtaskRow: Boolean = true, /** Whether a real list uses the bottom quick-add bar instead of the FAB. */ @@ -65,8 +68,8 @@ class TaskListViewModel @Inject constructor( val tasks = repository.tasks(f) val content: kotlinx.coroutines.flow.Flow = when (f) { is TaskFilter.OfList -> - combine(tasks, repository.taskLists()) { list, lists -> - TaskListUiState.Content(list, lists.firstOrNull { it.id == f.listId }?.name) + combine(tasks, repository.taskLists()) { rows, lists -> + TaskListUiState.Content(rows, lists.firstOrNull { it.id == f.listId }) } is TaskFilter.Smart -> tasks.map { TaskListUiState.Content(it) } @@ -87,7 +90,10 @@ class TaskListViewModel @Inject constructor( } } .onStart { emit(TaskListUiState.Loading) } - .catch { emit(TaskListUiState.Failure) } + // Recover rather than terminate: a provider hiccup (mid-update, + // permission not yet granted) shows Failure but keeps retrying, + // so the screen heals itself instead of staying stuck. + .recoveringFromProviderFailure { TaskListUiState.Failure } } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskListUiState.Loading) @@ -112,6 +118,9 @@ class TaskListViewModel @Inject constructor( combine(ids.map { id -> repository.subtasks(id).map { id to it } }) { it.toMap() } } } + // Without this an exception here escapes stateIn's coroutine, past + // viewModelScope's SupervisorJob, and crashes the process. + .recoveringFromProviderFailure { emptyMap() } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap()) /** The screen reports which expanded parents need their children fetched. */ @@ -120,7 +129,7 @@ class TaskListViewModel @Inject constructor( fun bind(taskFilter: TaskFilter) { filter.value = taskFilter } fun toggleComplete(task: Task) = viewModelScope.launch { - runCatching { repository.setCompleted(task.taskId, !task.isCompleted) } + runCatching { repository.setCompleted(task.taskId, task.occurrenceStart, !task.isCompleted) } } /** Swipe-delete: hide the row now; the screen's snackbar commits or restores it. */ @@ -149,6 +158,36 @@ class TaskListViewModel @Inject constructor( runCatching { repository.createTask(TaskForm(title = title, listId = listId)) } } + private val _listWriteFailure = MutableStateFlow(null) + + /** Set when a list write is refused; the screen shows it and clears it. */ + val listWriteFailure: StateFlow = _listWriteFailure.asStateFlow() + + private val _listDeleted = MutableStateFlow(false) + + /** Flips once the list this screen shows is really gone, so it can leave. */ + val listDeleted: StateFlow = _listDeleted.asStateFlow() + + fun clearListWriteFailure() { _listWriteFailure.value = null } + + /** Rename / recolour the list this screen is showing. */ + fun updateList(listId: Long, name: String, color: Int) = viewModelScope.launch { + if (name.isBlank()) return@launch + runCatching { repository.updateList(listId, name.trim(), color) } + .onFailure { _listWriteFailure.value = ListWriteFailure.SAVE } + } + + /** + * Delete the list **and its tasks**. The screen navigates away on + * [listDeleted], not on the call — leaving first would strand a refusal on a + * screen that no longer exists. + */ + fun deleteList(listId: Long) = viewModelScope.launch { + runCatching { repository.deleteList(listId) } + .onSuccess { _listDeleted.value = true } + .onFailure { _listWriteFailure.value = ListWriteFailure.DELETE } + } + /** Inline "add subtask" from an expanded list group — files it under [parent]. */ fun quickAddSubtask(parent: Task, title: String) = viewModelScope.launch { if (title.isBlank() || parent.listId <= 0L) return@launch diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1f8b7cd..dd0c5a8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -122,7 +122,32 @@ Lists New task Could not read your tasks. - No task lists yet. Add one in your tasks app or with the + button. + No task lists yet. + Create a list + + + New list + New list + Edit list + List name + Colour + Delete list + Could not save the list. + Could not delete the list. + Delete list? + “%1$s” and all of its tasks will be deleted. This can\'t be undone. + Mauve + Red + Orange + Amber + Olive + Green + Teal + Cyan + Blue + Indigo + Purple + Pink Today Overdue Upcoming @@ -221,6 +246,37 @@ Bottom quick-add bar Add tasks from a bar pinned to the bottom of a list, instead of the floating button + Use this device\'s storage instead + + + Storage + Where tasks are kept, and export + Task store + Each store keeps its own tasks. Switching does not move them across — export first if you want a copy. + On this device + Agendula\'s own storage. Nothing else to install. + Another task app + Share tasks with the app that syncs them + No compatible task app is installed + Permission denied + The other app\'s tasks stay unreachable until you allow access. Tap to open app settings. + Export tasks + Save your lists as iCalendar files + One .ics file per list, readable by other task and calendar apps. The ticked lists go to a folder you pick, or into a single zip. + No lists to export + Save to a folder + Save as a zip file + Exporting… + + Exported %1$d list + Exported %1$d lists + + The export could not be written + The chosen folder could not be opened + The chosen folder is not writable + A file could not be created in the chosen folder + Access to the chosen location was lost + %1$d minute before diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml index 87d1f20..8d670da 100644 --- a/app/src/main/res/xml/backup_rules.xml +++ b/app/src/main/res/xml/backup_rules.xml @@ -1,4 +1,22 @@ - + + + + + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index c6ba7b9..c7530b1 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -1,8 +1,21 @@ + - + + + + + + + + diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/export/TaskExporterTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/export/TaskExporterTest.kt new file mode 100644 index 0000000..b477620 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/export/TaskExporterTest.kt @@ -0,0 +1,61 @@ +package de.jeanlucmakiola.agendula.data.export + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** + * The export file name. The user picks the folder, so whatever comes out of here + * is what they will be looking at in a file manager a year from now. + */ +class TaskExporterTest { + + private fun name(listName: String, id: Long = 3L) = TaskExporter.fileNameFor(listName, id) + + @Test + fun `keeps a plain name readable`() { + assertThat(name("Groceries")).isEqualTo("Groceries-3.ics") + } + + @Test + fun `replaces characters a filesystem would reject`() { + // SAF can land on FAT32 (an SD card), where these are simply illegal. + val result = name("Work / Home: notes?") + assertThat(result).doesNotContain("/") + assertThat(result).doesNotContain(":") + assertThat(result).doesNotContain("?") + assertThat(result).endsWith("-3.ics") + } + + @Test + fun `keeps the id so same-named lists cannot collide`() { + // Two accounts may each have a list called "Personal"; without the id one + // export would silently overwrite the other. + assertThat(name("Personal", 1)).isNotEqualTo(name("Personal", 2)) + } + + @Test + fun `falls back when the name has nothing usable in it`() { + assertThat(name("///")).isEqualTo("list-3.ics") + assertThat(name("")).isEqualTo("list-3.ics") + } + + @Test + fun `does not leave dangling separators`() { + assertThat(name(" Shopping ")).isEqualTo("Shopping-3.ics") + } + + @Test + fun `caps the length`() { + // Many filesystems stop at 255 bytes for a name; a pathological list title + // should not be the thing that fails an export. + assertThat(name("x".repeat(500)).length).isAtMost(80) + } + + @Test + fun `keeps non-latin names instead of blanking them`() { + // isLetterOrDigit is Unicode-aware, so these survive rather than collapsing + // to the "list" fallback. + assertThat(name("Einkäufe")).isEqualTo("Einkäufe-3.ics") + assertThat(name("買い物")).isEqualTo("買い物-3.ics") + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt new file mode 100644 index 0000000..c73eb3f --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt @@ -0,0 +1,198 @@ +package de.jeanlucmakiola.agendula.data.tasks + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +/** + * The storage-mode decision, which is the part of [ProviderResolver] with real + * consequences: pick wrong for a returning user and the app opens on an empty + * store where their tasks used to be. + */ +class ProviderResolverTest { + + /** + * A [ProviderEnvironment] with no Android in it. + * + * @param installed authority -> declaring package, i.e. what is on the device. + * @param granted permissions this app currently holds. + */ + private class FakeEnvironment( + val installed: Map = emptyMap(), + val granted: Set = emptySet(), + ) : ProviderEnvironment { + override fun packageDeclaring(authority: String): String? = installed[authority] + override fun isGranted(permission: String): Boolean = permission in granted + override fun appLabel(packageName: String): String? = packageName + } + + private val openTasks = ProviderResolver.EXTERNAL_CANDIDATES.first { it.authority == "org.dmfs.tasks" } + + private fun resolver( + installed: Map = emptyMap(), + granted: Set = emptySet(), + mode: StorageMode? = null, + ) = ProviderResolver(FakeEnvironment(installed, granted)).apply { storageMode = mode } + + private val openTasksInstalled = mapOf("org.dmfs.tasks" to "org.dmfs.tasks") + private val openTasksGranted = setOf(openTasks.readPermission, openTasks.writePermission) + + @Nested + inner class OwnStore { + + @Test + fun `is readable without anything installed or granted`() { + // Guards a real regression: the reminder engine used to gate on + // resolve() != null, which is exactly what OWN returns, so every + // reminder was cleared the moment our own store became the default. + assertThat(resolver(mode = StorageMode.OWN).canReadStore()).isTrue() + } + + @Test + fun `resolves to no provider at all`() { + // Room has no authority and no ContentResolver, so there is nothing + // here to resolve — which is the point. Callers that need to tell this + // apart from "External, none installed" ask mode(). + val resolver = resolver(mode = StorageMode.OWN) + assertThat(resolver.resolve()).isNull() + assertThat(resolver.mode()).isEqualTo(StorageMode.OWN) + } + } + + @Nested + inner class AutoMode { + + @Test + fun `a fresh install with nothing else present gets our own store`() { + assertThat(resolver().autoMode()).isEqualTo(StorageMode.OWN) + } + + @Test + fun `an upgrading user who already granted OpenTasks stays on it`() { + // Holding a dangerous permission means a previous version asked and they + // agreed — the signature of an existing Posture A user. Sending them to + // our empty bundled store would read as data loss. + val resolver = resolver(installed = openTasksInstalled, granted = openTasksGranted) + assertThat(resolver.autoMode()).isEqualTo(StorageMode.EXTERNAL) + assertThat(resolver.resolve()?.authority).isEqualTo("org.dmfs.tasks") + } + + @Test + fun `OpenTasks merely installed is not enough`() { + // Someone who has OpenTasks for unrelated reasons, and never granted us + // anything, has no data with us there. Our own store is right for them. + assertThat(resolver(installed = openTasksInstalled).autoMode()).isEqualTo(StorageMode.OWN) + } + + @Test + fun `a half-granted external provider does not count`() { + val resolver = resolver( + installed = openTasksInstalled, + granted = setOf(openTasks.readPermission), + ) + assertThat(resolver.autoMode()).isEqualTo(StorageMode.OWN) + } + } + + @Nested + inner class ExplicitChoice { + + @Test + fun `overrides the automatic answer in both directions`() { + val wouldBeExternal = FakeEnvironment(openTasksInstalled, openTasksGranted) + + val forcedOwn = ProviderResolver(wouldBeExternal).apply { storageMode = StorageMode.OWN } + assertThat(forcedOwn.mode()).isEqualTo(StorageMode.OWN) + assertThat(forcedOwn.resolve()).isNull() + + val forcedExternal = ProviderResolver(FakeEnvironment()).apply { storageMode = StorageMode.EXTERNAL } + assertThat(forcedExternal.mode()).isEqualTo(StorageMode.EXTERNAL) + assertThat(forcedExternal.resolve()).isNull() + } + + @Test + fun `external with no provider installed resolves to nothing`() { + // Drives the "install a tasks provider" gate rather than silently + // falling back to our own store behind the user's back. + assertThat(resolver(mode = StorageMode.EXTERNAL).resolve()).isNull() + } + + @Test + fun `external is unreadable until a provider is installed and granted`() { + assertThat(resolver(mode = StorageMode.EXTERNAL).canReadStore()).isFalse() + assertThat( + resolver(installed = openTasksInstalled, mode = StorageMode.EXTERNAL).canReadStore(), + ).isFalse() + assertThat( + resolver( + installed = openTasksInstalled, + granted = openTasksGranted, + mode = StorageMode.EXTERNAL, + ).canReadStore(), + ).isTrue() + } + + @Test + fun `external still requires the runtime permission`() { + val resolver = resolver(installed = openTasksInstalled, mode = StorageMode.EXTERNAL) + val provider = resolver.resolve() + assertThat(provider).isNotNull() + assertThat(resolver.hasPermission(provider!!)).isFalse() + } + } + + @Nested + inner class ExternalCandidates { + + @Test + fun `prefer OpenTasks over tasks_org when both are installed`() { + val resolver = resolver( + installed = mapOf( + "org.dmfs.tasks" to "org.dmfs.tasks", + "org.tasks.opentasks" to "org.tasks", + ), + mode = StorageMode.EXTERNAL, + ) + assertThat(resolver.resolve()?.authority).isEqualTo("org.dmfs.tasks") + } + + @Test + fun `fall through to tasks_org when OpenTasks is absent`() { + val resolver = resolver( + installed = mapOf("org.tasks.opentasks" to "org.tasks"), + mode = StorageMode.EXTERNAL, + ) + assertThat(resolver.resolve()?.packageName).isEqualTo("org.tasks") + } + + @Test + fun `a mode change notifies listeners once, and only on a real change`() { + // What store observers hang off: a live flow is bound to one store, so + // it has to be told when the store underneath it is swapped. + val resolver = resolver(mode = StorageMode.OWN) + var fired = 0 + val handle = resolver.onModeChanged { fired++ } + + resolver.storageMode = StorageMode.OWN + assertThat(fired).isEqualTo(0) + + resolver.storageMode = StorageMode.EXTERNAL + assertThat(fired).isEqualTo(1) + + handle.close() + resolver.storageMode = StorageMode.OWN + assertThat(fired).isEqualTo(1) + } + + @Test + fun `never name an authority of ours`() { + // EXTERNAL must mean "somebody else's store", and Agendula publishes no + // provider at all any more. + assertThat( + ProviderResolver.EXTERNAL_CANDIDATES.none { + it.authority.startsWith("de.jeanlucmakiola") + }, + ).isTrue() + } + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapperTest.kt index 7d70aa9..781b02a 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapperTest.kt @@ -36,7 +36,6 @@ class TaskMapperTest { val task = TaskMapper.task(reader) - assertThat(task.id).isEqualTo(42L) assertThat(task.taskId).isEqualTo(7L) assertThat(task.listId).isEqualTo(3L) assertThat(task.title).isEqualTo("Buy milk") @@ -50,6 +49,65 @@ class TaskMapperTest { assertThat(task.isSubtask).isTrue() } + @Test + fun `an occurrence is identified by its recurrence-id anchor`() { + fun occurrence(columns: Map) = + TaskMapper.task(MapColumnReader(columns + (Tasks.RRULE to "FREQ=DAILY"))) + + // instance_original_time is the provider's own RECURRENCE-ID and wins. + val anchored = occurrence( + mapOf( + Instances.TASK_ID to 7L, + Instances.INSTANCE_ORIGINAL_TIME to 500L, + Instances.INSTANCE_START to 900L, + ), + ) + assertThat(anchored.occurrenceStart?.toEpochMilliseconds()).isEqualTo(500L) + assertThat(anchored.occurrenceKey).isEqualTo("7@500") + + // Older provider schemas omit it; the occurrence's start reconstructs it. + val byStart = occurrence(mapOf(Instances.TASK_ID to 7L, Instances.INSTANCE_START to 900L)) + assertThat(byStart.occurrenceStart?.toEpochMilliseconds()).isEqualTo(900L) + + // A series carrying only DUE anchors on the due date instead. + val byDue = occurrence(mapOf(Instances.TASK_ID to 7L, Instances.INSTANCE_DUE to 1_200L)) + assertThat(byDue.occurrenceStart?.toEpochMilliseconds()).isEqualTo(1_200L) + } + + @Test + fun `a non-recurring task has no occurrence anchor and keys by task id`() { + val task = TaskMapper.task( + MapColumnReader(mapOf(Tasks.ID to 4L, Instances.INSTANCE_START to 500L)), + ) + assertThat(task.occurrenceStart).isNull() + assertThat(task.occurrenceKey).isEqualTo("4") + } + + @Test + fun `recurrence is detected from rrule when is_recurring is absent`() { + // tasks.org's bundled provider is DB 22 and has no `is_recurring` column; + // reading it alone would report the series as one-off and send its edits + // to the master row, re-anchoring the whole thing. + val task = TaskMapper.task( + MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.RRULE to "FREQ=WEEKLY;BYDAY=MO")), + ) + assertThat(task.isRecurring).isTrue() + } + + @Test + fun `recurrence is detected from rdate alone`() { + val task = TaskMapper.task( + MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.RDATE to "20260720T090000Z")), + ) + assertThat(task.isRecurring).isTrue() + } + + @Test + fun `a plain task is not recurring`() { + val task = TaskMapper.task(MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.TITLE to "One-off"))) + assertThat(task.isRecurring).isFalse() + } + @Test fun `falls back to instance id when task_id missing, and list color when no task color`() { val task = TaskMapper.task( diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapperTest.kt index faa5034..4ec590e 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapperTest.kt @@ -85,6 +85,57 @@ class TaskWriteMapperTest { assertThat(values[Tasks.TZ]).isNull() } + @Test + fun `all-day timestamps are pinned to UTC midnight`() { + // 2026-07-20T22:00Z — i.e. local midnight on the 21st in Berlin (UTC+2). + // The provider resolves all-day dates against UTC, so storing this as-is + // would land the task on the 20th for anyone reading it back. + val berlinMidnight = Instant.fromEpochMilliseconds(1_784_412_000_000L) + val values = TaskWriteMapper.taskValues( + TaskForm(title = "Holiday", listId = 1L, start = berlinMidnight, due = berlinMidnight, isAllDay = true), + tzId = "Europe/Berlin", + ) + + val dayMs = 24L * 60 * 60 * 1000 + assertThat(values[Tasks.DUE] as Long % dayMs).isEqualTo(0L) + assertThat(values[Tasks.DTSTART] as Long % dayMs).isEqualTo(0L) + } + + @Test + fun `timed timestamps are written untouched`() { + val at = Instant.fromEpochMilliseconds(1_784_412_345_678L) + val values = TaskWriteMapper.taskValues( + TaskForm(title = "Standup", listId = 1L, start = at, due = at), + tzId = "Europe/Berlin", + ) + assertThat(values[Tasks.DTSTART]).isEqualTo(1_784_412_345_678L) + assertThat(values[Tasks.DUE]).isEqualTo(1_784_412_345_678L) + } + + @Test + fun `duration is always cleared so it cannot collide with due`() { + // The provider validates the *merged* row and throws "Only one of DUE or + // DURATION must be supplied" if the stored row still carries a duration. + val values = TaskWriteMapper.taskValues( + TaskForm(title = "x", listId = 1L, due = Instant.fromEpochMilliseconds(5_000L)), + tzId = "UTC", + ) + assertThat(values.containsKey(Tasks.DURATION)).isTrue() + assertThat(values[Tasks.DURATION]).isNull() + } + + @Test + fun `instance values drop list and parent, which an override cannot express`() { + val form = TaskForm(title = "x", listId = 4L, parentId = 7L, due = Instant.fromEpochMilliseconds(1_000L)) + val values = TaskWriteMapper.instanceValues(form, tzId = "UTC") + + assertThat(values.containsKey(Tasks.LIST_ID)).isFalse() + assertThat(values.containsKey(Tasks.PARENT_ID)).isFalse() + // …but still carries the edit itself. + assertThat(values[Tasks.TITLE]).isEqualTo("x") + assertThat(values[Tasks.DUE]).isEqualTo(1_000L) + } + @Test fun `completion sets status, percent and timestamp, un-completion clears them`() { val done = TaskWriteMapper.completionValues(completed = true, nowMillis = 999L) @@ -97,6 +148,22 @@ class TaskWriteMapperTest { assertThat(undone[Tasks.COMPLETED]).isNull() } + @Test + fun `alarm carries every column the provider's validator demands`() { + val values = TaskWriteMapper.alarmValues(taskId = 12L, minutesBeforeDue = 30) + + assertThat(values[TasksContract.Properties.TASK_ID]).isEqualTo(12L) + assertThat(values[TasksContract.Properties.MIMETYPE]) + .isEqualTo("vnd.android.cursor.item/alarm") + assertThat(values[TasksContract.Alarm.MINUTES_BEFORE]).isEqualTo(30) + // REFERENCE must be present and non-negative, ALARM_TYPE present and + // non-zero (0 is excluded from the provider's has_alarms count). + assertThat(values[TasksContract.Alarm.REFERENCE]).isEqualTo(TasksContract.Alarm.REFERENCE_DUE) + assertThat(values[TasksContract.Alarm.ALARM_TYPE]).isEqualTo(TasksContract.Alarm.TYPE_MESSAGE) + // property_id must be absent or the insert is rejected. + assertThat(values.containsKey(TasksContract.Properties.PROPERTY_ID)).isFalse() + } + @Test fun `local list uses the LOCAL account`() { val values = TaskWriteMapper.localListValues("Inbox", 0x123) diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/ConvertersTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/ConvertersTest.kt new file mode 100644 index 0000000..2ef8067 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/ConvertersTest.kt @@ -0,0 +1,53 @@ +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.priorityFromICal +import de.jeanlucmakiola.agendula.domain.toICal +import de.jeanlucmakiola.agendula.domain.TaskStatus +import org.junit.jupiter.api.Test +import kotlin.time.Instant + +class ConvertersTest { + + @Test + fun `round-trips an instant through epoch millis`() { + val value = Instant.fromEpochMilliseconds(1_700_000_000_123) + + val stored = Converters.instantToMillis(value) + + assertThat(stored).isEqualTo(1_700_000_000_123) + assertThat(Converters.instantFromMillis(stored)).isEqualTo(value) + } + + @Test + fun `maps null time both ways`() { + assertThat(Converters.instantToMillis(null)).isNull() + assertThat(Converters.instantFromMillis(null)).isNull() + } + + @Test + fun `round-trips every status through the domain encoding`() { + TaskStatus.entries.forEach { status -> + assertThat(Converters.statusFrom(Converters.statusToInt(status))).isEqualTo(status) + } + } + + @Test + fun `priority is stored raw, so an off-bucket value survives`() { + // There is no priority converter on purpose: PRIORITY:3 is a legitimate + // value a server can send, and Priority buckets 1..4 into HIGH. Bucketing + // on the way in would rewrite it as 1 and lose it on the next round-trip. + assertThat(priorityFromICal(3)).isEqualTo(Priority.HIGH) + assertThat(Priority.HIGH.toICal()).isEqualTo(1) + } + + @Test + fun `round-trips an alarm reference and falls back on an unknown one`() { + AlarmReference.entries.forEach { reference -> + assertThat(Converters.alarmReferenceFrom(Converters.alarmReferenceToString(reference))) + .isEqualTo(reference) + } + assertThat(Converters.alarmReferenceFrom("NONSENSE")).isEqualTo(AlarmReference.DUE) + } +} 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 new file mode 100644 index 0000000..f5c55bd --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriterTest.kt @@ -0,0 +1,152 @@ +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.TaskStatus +import org.junit.jupiter.api.Test +import kotlin.time.Instant + +private val NOW = Instant.fromEpochMilliseconds(1_768_467_600_000) +private const val ZONE = "Europe/Berlin" + +private fun task( + status: TaskStatus = TaskStatus.NEEDS_ACTION, + percentComplete: Int? = null, + completedAt: Instant? = null, +) = TaskEntity( + id = 1, + listId = 1, + uid = "uid-1", + status = status, + percentComplete = percentComplete, + completedAt = completedAt, +) + +private fun form( + title: String = "task", + percentComplete: Int? = null, + start: Instant? = null, + due: Instant? = null, + isAllDay: Boolean = false, +) = TaskForm( + title = title, + listId = 1, + percentComplete = percentComplete, + start = start, + due = due, + isAllDay = isAllDay, +) + +class TaskFormWriterTest { + + @Test + fun `mints the task with its uid and creation time`() { + val entity = TaskFormWriter.newTask(form(title = " Buy milk "), "uid-9", NOW, ZONE) + + assertThat(entity.uid).isEqualTo("uid-9") + assertThat(entity.title).isEqualTo("Buy milk") + assertThat(entity.createdAt).isEqualTo(NOW) + assertThat(entity.lastModified).isEqualTo(NOW) + assertThat(entity.isDirty).isTrue() + } + + @Test + fun `progress and status move together in both directions`() { + assertThat(TaskFormWriter.apply(task(), form(percentComplete = 100), NOW, ZONE).status) + .isEqualTo(TaskStatus.COMPLETED) + assertThat(TaskFormWriter.apply(task(), form(percentComplete = 40), NOW, ZONE).status) + .isEqualTo(TaskStatus.IN_PROCESS) + assertThat(TaskFormWriter.apply(task(), form(percentComplete = 0), NOW, ZONE).status) + .isEqualTo(TaskStatus.NEEDS_ACTION) + } + + @Test + fun `dropping below 100 percent reopens the task`() { + // The provider auto-completed at 100% but would not reopen below it, which + // stranded a task "done at 75%". TaskWriteMapper works around that for + // External mode; on our own store the rule is simply symmetric. + val completed = task(status = TaskStatus.COMPLETED, percentComplete = 100, completedAt = NOW) + + val reopened = TaskFormWriter.apply(completed, form(percentComplete = 75), NOW, ZONE) + + assertThat(reopened.status).isEqualTo(TaskStatus.IN_PROCESS) + assertThat(reopened.completedAt).isNull() + } + + @Test + fun `a form with no percent leaves the completion state alone`() { + val completed = task(status = TaskStatus.COMPLETED, percentComplete = 100, completedAt = NOW) + + val saved = TaskFormWriter.apply(completed, form(title = "renamed"), NOW, ZONE) + + assertThat(saved.status).isEqualTo(TaskStatus.COMPLETED) + assertThat(saved.completedAt).isEqualTo(NOW) + } + + @Test + fun `re-saving a finished task keeps its original completion time`() { + val earlier = Instant.fromEpochMilliseconds(1_000_000) + val completed = task(status = TaskStatus.COMPLETED, percentComplete = 100, completedAt = earlier) + + val saved = TaskFormWriter.apply(completed, form(percentComplete = 100), NOW, ZONE) + + assertThat(saved.completedAt).isEqualTo(earlier) + } + + @Test + fun `all-day times are pinned to UTC midnight`() { + // Date-only in iCalendar. Storing a local-midnight instant would land on the + // previous day for anyone west of UTC. + val midMorning = Instant.fromEpochMilliseconds(1_768_467_600_000) + + val saved = TaskFormWriter.apply( + task(), + form(start = midMorning, due = midMorning, isAllDay = true), + NOW, + ZONE, + ) + + assertThat(saved.dtstart!!.toEpochMilliseconds() % (24L * 60 * 60 * 1000)).isEqualTo(0) + assertThat(saved.due!!.toEpochMilliseconds() % (24L * 60 * 60 * 1000)).isEqualTo(0) + assertThat(saved.timezone).isNull() + } + + @Test + fun `a timed task records the zone, an undated one does not`() { + val timed = TaskFormWriter.apply(task(), form(due = NOW), NOW, ZONE) + assertThat(timed.timezone).isEqualTo(ZONE) + + val undated = TaskFormWriter.apply(task(), form(), NOW, ZONE) + assertThat(undated.timezone).isNull() + } + + @Test + fun `writing a due date clears any duration`() { + // RFC 5545 §3.6.2: DUE and DURATION are mutually exclusive. + val withDuration = task().copy(duration = "PT1H") + + assertThat(TaskFormWriter.apply(withDuration, form(due = NOW), NOW, ZONE).duration).isNull() + } + + @Test + fun `the complete toggle sets and clears the whole triple`() { + val done = TaskFormWriter.completed(task(), completed = true, now = NOW) + assertThat(done.status).isEqualTo(TaskStatus.COMPLETED) + assertThat(done.percentComplete).isEqualTo(100) + assertThat(done.completedAt).isEqualTo(NOW) + + val reopened = TaskFormWriter.completed(done, completed = false, now = NOW) + assertThat(reopened.status).isEqualTo(TaskStatus.NEEDS_ACTION) + assertThat(reopened.percentComplete).isNull() + assertThat(reopened.completedAt).isNull() + } + + @Test + fun `priority is written as the raw iCalendar integer`() { + assertThat(TaskFormWriter.apply(task(), form().copy(priority = Priority.HIGH), NOW, ZONE).priority) + .isEqualTo(1) + assertThat(TaskFormWriter.apply(task(), form().copy(priority = Priority.NONE), NOW, ZONE).priority) + .isEqualTo(0) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/AllDayTimeTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/AllDayTimeTest.kt new file mode 100644 index 0000000..957192f --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/AllDayTimeTest.kt @@ -0,0 +1,60 @@ +package de.jeanlucmakiola.agendula.domain + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import java.time.LocalDate +import java.time.ZoneId +import kotlin.time.Instant + +class AllDayTimeTest { + + private val berlin = ZoneId.of("Europe/Berlin") // UTC+2 in July + private val newYork = ZoneId.of("America/New_York") // UTC-4 in July + private val julyTwentieth = LocalDate.of(2026, 7, 20) + + @Test + fun `an all-day instant is UTC midnight of its date`() { + val instant = allDayInstantOf(julyTwentieth) + assertThat(instant.toEpochMilliseconds() % (24L * 60 * 60 * 1000)).isEqualTo(0L) + assertThat(instant.calendarDate(allDay = true)).isEqualTo(julyTwentieth) + } + + @Test + fun `an all-day date reads the same everywhere, unlike a timed one`() { + val allDay = allDayInstantOf(julyTwentieth) + // The whole point: zone must not change which day an all-day value denotes. + assertThat(allDay.calendarDate(allDay = true, zone = berlin)).isEqualTo(julyTwentieth) + assertThat(allDay.calendarDate(allDay = true, zone = newYork)).isEqualTo(julyTwentieth) + // Read as a timed value in New York it would slip to the 19th — the bug. + assertThat(allDay.calendarDate(allDay = false, zone = newYork)).isEqualTo(julyTwentieth.minusDays(1)) + } + + @Test + fun `toggling all-day off keeps the day and lands on local midnight`() { + val allDay = allDayInstantOf(julyTwentieth) + val timed = allDay.rebasedForAllDay(allDay = false, zone = berlin) + + assertThat(timed.calendarDate(allDay = false, zone = berlin)).isEqualTo(julyTwentieth) + val local = java.time.Instant.ofEpochMilli(timed.toEpochMilliseconds()).atZone(berlin) + assertThat(local.toLocalTime()).isEqualTo(java.time.LocalTime.MIDNIGHT) + } + + @Test + fun `toggling all-day on keeps the day the user was looking at`() { + // 2026-07-20T23:30 in Berlin — late enough that a naive UTC read slips a day. + val lateEvening = Instant.fromEpochMilliseconds( + julyTwentieth.atTime(23, 30).atZone(berlin).toInstant().toEpochMilli(), + ) + val allDay = lateEvening.rebasedForAllDay(allDay = true, zone = berlin) + + assertThat(allDay.calendarDate(allDay = true)).isEqualTo(julyTwentieth) + } + + @Test + fun `round-tripping the toggle is stable`() { + val original = allDayInstantOf(julyTwentieth) + val there = original.rebasedForAllDay(allDay = false, zone = newYork) + val back = there.rebasedForAllDay(allDay = true, zone = newYork) + assertThat(back).isEqualTo(original) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskSortingTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskSortingTest.kt index 3af991d..d1853d9 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskSortingTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskSortingTest.kt @@ -17,7 +17,7 @@ class TaskSortingTest { val sorted = listOf(completed, noDate, dueLater, dueSooner).sortedWith(TaskSorting.DEFAULT) - assertThat(sorted.map { it.id }).containsExactly(3L, 2L, 4L, 1L).inOrder() + assertThat(sorted.map { it.taskId }).containsExactly(3L, 2L, 4L, 1L).inOrder() } @Test @@ -27,6 +27,6 @@ class TaskSortingTest { val sorted = listOf(low, high).sortedWith(TaskSorting.DEFAULT) - assertThat(sorted.map { it.id }).containsExactly(2L, 1L).inOrder() + assertThat(sorted.map { it.taskId }).containsExactly(2L, 1L).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 11f58c6..6967c97 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/domain/TestTasks.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/TestTasks.kt @@ -11,7 +11,6 @@ fun testTask( priority: Priority = Priority.NONE, due: Instant? = null, ): Task = Task( - id = id, taskId = id, listId = listId, title = title, @@ -32,6 +31,7 @@ fun testTask( accountName = null, parentId = null, isRecurring = false, + occurrenceStart = null, distanceFromCurrent = 0, created = null, lastModified = null, diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/export/ICalendarWriterTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/export/ICalendarWriterTest.kt new file mode 100644 index 0000000..86c6b8c --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/export/ICalendarWriterTest.kt @@ -0,0 +1,313 @@ +package de.jeanlucmakiola.agendula.domain.export + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.agendula.domain.Priority +import de.jeanlucmakiola.agendula.domain.TaskStatus +import de.jeanlucmakiola.agendula.domain.allDayInstantOf +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import java.time.LocalDate +import kotlin.time.Instant + +/** + * The export format. Worth testing closely: an export is only as good as its + * ability to be read back, and nothing about a malformed `.ics` is obvious until + * someone actually needs the backup. + */ +class ICalendarWriterTest { + + private fun task( + taskId: Long = 1L, + uid: String? = null, + title: String = "Buy oat milk", + description: String? = null, + location: String? = null, + url: String? = null, + priority: Priority = Priority.NONE, + status: TaskStatus = TaskStatus.NEEDS_ACTION, + percentComplete: Int? = null, + start: Instant? = null, + due: Instant? = null, + isAllDay: Boolean = false, + completedAt: Instant? = null, + created: Instant? = null, + lastModified: Instant? = null, + rrule: String? = null, + rdate: String? = null, + parentId: Long? = null, + ) = ExportTask( + taskId, uid, title, description, location, url, priority, status, percentComplete, + start, due, isAllDay, completedAt, created, lastModified, rrule, rdate, parentId, + ) + + private fun write(vararg tasks: ExportTask, name: String = "Groceries"): String = + ICalendarWriter.write(ExportList(1L, name, "local", tasks.toList())) + + /** Unfolds the way an importer does, so assertions can read logical lines. */ + private fun String.unfolded(): String = replace("\r\n ", "") + + private fun linesOf(ics: String): List = ics.unfolded().split("\r\n").filter { it.isNotEmpty() } + + @Nested + inner class Structure { + + @Test + fun `wraps the todos in a calendar`() { + val lines = linesOf(write(task())) + assertThat(lines.first()).isEqualTo("BEGIN:VCALENDAR") + assertThat(lines.last()).isEqualTo("END:VCALENDAR") + assertThat(lines).containsAtLeast("VERSION:2.0", "BEGIN:VTODO", "END:VTODO") + } + + @Test + fun `uses CRLF line endings`() { + // RFC 5545 requires CRLF. Bare LF is the classic way an .ics is rejected + // by a strict importer while looking perfectly fine in an editor. + val ics = write(task()) + assertThat(ics).contains("\r\n") + assertThat(ics.replace("\r\n", "")).doesNotContain("\n") + } + + @Test + fun `carries the list name`() { + assertThat(linesOf(write(task(), name = "Shopping"))).contains("X-WR-CALNAME:Shopping") + } + + @Test + fun `every todo has a UID and a DTSTAMP`() { + // Both are mandatory; a VTODO missing either is invalid. + val lines = linesOf(write(task(), task(taskId = 2))) + assertThat(lines.count { it.startsWith("UID:") }).isEqualTo(2) + assertThat(lines.count { it.startsWith("DTSTAMP:") }).isEqualTo(2) + } + } + + @Nested + inner class Identity { + + @Test + fun `prefers the synced UID`() { + assertThat(linesOf(write(task(uid = "abc-123@example.org")))) + .contains("UID:abc-123@example.org") + } + + @Test + fun `synthesises a stable UID for a local task`() { + // Local tasks never get a UID from the provider (only a sync adapter may + // assign one), and re-importing UID-less todos would duplicate rather + // than match them. + val first = ICalendarWriter.uidFor(task(taskId = 42)) + val second = ICalendarWriter.uidFor(task(taskId = 42)) + assertThat(first).isEqualTo(second) + assertThat(first).contains("42") + } + + @Test + fun `distinct tasks get distinct UIDs`() { + assertThat(ICalendarWriter.uidFor(task(taskId = 1))) + .isNotEqualTo(ICalendarWriter.uidFor(task(taskId = 2))) + } + + @Test + fun `blank stored UID falls back to the synthesised one`() { + assertThat(ICalendarWriter.uidFor(task(taskId = 7, uid = " "))).contains("7") + } + } + + @Nested + inner class Dates { + + private val noon = Instant.fromEpochMilliseconds(1_754_136_000_000L) // 2025-08-02T12:00:00Z + + @Test + fun `timed values are written in UTC`() { + assertThat(linesOf(write(task(due = noon)))).contains("DUE:20250802T120000Z") + } + + @Test + fun `all-day values are date-only`() { + // A DATE-TIME here would drift by a day for anyone east or west of UTC — + // the exact bug the AllDayTime convention exists to prevent. + val due = allDayInstantOf(LocalDate.of(2026, 8, 2)) + val lines = linesOf(write(task(due = due, isAllDay = true))) + assertThat(lines).contains("DUE;VALUE=DATE:20260802") + } + + @Test + fun `completion is always a UTC date-time even when all-day`() { + val lines = linesOf( + write(task(isAllDay = true, status = TaskStatus.COMPLETED, completedAt = noon)), + ) + assertThat(lines).contains("COMPLETED:20250802T120000Z") + } + + @Test + fun `absent dates emit no property at all`() { + val lines = linesOf(write(task())) + assertThat(lines.none { it.startsWith("DUE") }).isTrue() + assertThat(lines.none { it.startsWith("DTSTART") }).isTrue() + } + } + + @Nested + inner class Fields { + + @Test + fun `maps every status to its iCalendar name`() { + fun statusLine(status: TaskStatus) = + linesOf(write(task(status = status))).first { it.startsWith("STATUS:") } + + assertThat(statusLine(TaskStatus.NEEDS_ACTION)).isEqualTo("STATUS:NEEDS-ACTION") + assertThat(statusLine(TaskStatus.IN_PROCESS)).isEqualTo("STATUS:IN-PROCESS") + assertThat(statusLine(TaskStatus.COMPLETED)).isEqualTo("STATUS:COMPLETED") + assertThat(statusLine(TaskStatus.CANCELLED)).isEqualTo("STATUS:CANCELLED") + } + + @Test + fun `omits priority when there is none`() { + // PRIORITY:0 means "undefined" but reads as a real value to some + // importers; leaving it out is unambiguous. + assertThat(linesOf(write(task())).none { it.startsWith("PRIORITY") }).isTrue() + assertThat(linesOf(write(task(priority = Priority.HIGH)))).contains("PRIORITY:1") + } + + @Test + fun `clamps percent complete into range`() { + assertThat(linesOf(write(task(percentComplete = 140)))).contains("PERCENT-COMPLETE:100") + assertThat(linesOf(write(task(percentComplete = -5)))).contains("PERCENT-COMPLETE:0") + } + + @Test + fun `passes recurrence through unchanged`() { + val lines = linesOf(write(task(rrule = "FREQ=WEEKLY;BYDAY=MO,WE"))) + assertThat(lines).contains("RRULE:FREQ=WEEKLY;BYDAY=MO,WE") + } + + @Test + fun `does not escape a URL`() { + // URL is a URI, not TEXT. Escaping its commas would corrupt the address. + val lines = linesOf(write(task(url = "https://example.org/a,b;c"))) + assertThat(lines).contains("URL:https://example.org/a,b;c") + } + + @Test + fun `skips blank optional fields`() { + val lines = linesOf(write(task(description = " ", location = "", url = ""))) + assertThat(lines.none { it.startsWith("DESCRIPTION") }).isTrue() + assertThat(lines.none { it.startsWith("LOCATION") }).isTrue() + assertThat(lines.none { it.startsWith("URL") }).isTrue() + } + } + + @Nested + inner class Subtasks { + + @Test + fun `links a child to its parent by UID`() { + val parent = task(taskId = 1, uid = "parent@example.org") + val child = task(taskId = 2, parentId = 1) + assertThat(linesOf(write(parent, child))) + .contains("RELATED-TO;RELTYPE=PARENT:parent@example.org") + } + + @Test + fun `resolves a parent that appears after the child`() { + // Nothing guarantees provider order, and a forward reference must still + // resolve or half the hierarchy silently disappears. + val child = task(taskId = 2, parentId = 1) + val parent = task(taskId = 1, uid = "parent@example.org") + assertThat(linesOf(write(child, parent))) + .contains("RELATED-TO;RELTYPE=PARENT:parent@example.org") + } + + @Test + fun `drops a link to a parent outside this list`() { + // A RELATED-TO pointing at a UID not in the file would dangle on import. + val orphan = task(taskId = 2, parentId = 999) + assertThat(linesOf(write(orphan)).none { it.startsWith("RELATED-TO") }).isTrue() + } + } + + @Nested + inner class Escaping { + + @Test + fun `escapes the special characters`() { + assertThat(ICalendarWriter.escapeText("a;b,c")).isEqualTo("a\\;b\\,c") + assertThat(ICalendarWriter.escapeText("line\nbreak")).isEqualTo("line\\nbreak") + assertThat(ICalendarWriter.escapeText("CRLF\r\nhere")).isEqualTo("CRLF\\nhere") + } + + @Test + fun `escapes backslashes first`() { + // Doing it later would re-escape the backslashes the other rules add, + // turning "a;b" into "a\\;b". + assertThat(ICalendarWriter.escapeText("back\\slash")).isEqualTo("back\\\\slash") + assertThat(ICalendarWriter.escapeText("a\\;b")).isEqualTo("a\\\\\\;b") + } + + @Test + fun `a multiline description stays one logical line`() { + val ics = write(task(description = "first\nsecond")) + assertThat(ics.unfolded()).contains("DESCRIPTION:first\\nsecond") + } + } + + @Nested + inner class Folding { + + @Test + fun `short lines are untouched`() { + assertThat(ICalendarWriter.fold("SUMMARY:short")).isEqualTo("SUMMARY:short") + } + + @Test + fun `long lines are folded to 75 octets`() { + val folded = ICalendarWriter.fold("SUMMARY:" + "a".repeat(200)) + folded.split("\r\n").forEachIndexed { index, segment -> + val octets = segment.toByteArray(Charsets.UTF_8).size + assertThat(octets).isAtMost(if (index == 0) 75 else 76) // 75 + the leading space + } + } + + @Test + fun `folding round-trips`() { + val original = "DESCRIPTION:" + "long text ".repeat(40) + assertThat(ICalendarWriter.fold(original).replace("\r\n ", "")).isEqualTo(original) + } + + @Test + fun `never splits a multi-byte character`() { + // The limit is in octets but an emoji is four of them; splitting mid + // sequence would emit invalid UTF-8 and mangle the title. + val emoji = "SUMMARY:" + "🌼".repeat(40) + val folded = ICalendarWriter.fold(emoji) + assertThat(folded.replace("\r\n ", "")).isEqualTo(emoji) + folded.split("\r\n").forEach { segment -> + // A broken surrogate pair round-trips through UTF-8 as U+FFFD. + assertThat(segment.toByteArray(Charsets.UTF_8).toString(Charsets.UTF_8)) + .isEqualTo(segment) + } + } + + @Test + fun `a long title survives the full write`() { + val title = "Remember to ".repeat(20) + assertThat(write(task(title = title)).unfolded()).contains("SUMMARY:$title") + } + } + + @Nested + inner class EmptyList { + + @Test + fun `still produces a valid calendar`() { + // An empty list is a real answer; a missing file is indistinguishable + // from a failed export. + val lines = linesOf(ICalendarWriter.write(ExportList(1L, "Empty", "local", emptyList()))) + assertThat(lines.first()).isEqualTo("BEGIN:VCALENDAR") + assertThat(lines.last()).isEqualTo("END:VCALENDAR") + assertThat(lines.none { it == "BEGIN:VTODO" }).isTrue() + } + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/recurrence/DistanceFromCurrentTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/recurrence/DistanceFromCurrentTest.kt new file mode 100644 index 0000000..2e0e9b1 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/recurrence/DistanceFromCurrentTest.kt @@ -0,0 +1,59 @@ +package de.jeanlucmakiola.agendula.domain.recurrence + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import kotlin.time.Instant + +class DistanceFromCurrentTest { + + private fun at(text: String) = Instant.parse(text) + + private val occurrences = listOf( + at("2025-01-05T09:00:00Z"), + at("2025-01-06T09:00:00Z"), + at("2025-01-07T09:00:00Z"), + at("2025-01-08T09:00:00Z"), + ) + + @Test + fun `the current occurrence is the first one at or after now`() { + val index = RecurrenceExpander.currentOccurrenceIndex(occurrences, at("2025-01-06T10:00:00Z")) + assertThat(index).isEqualTo(2) + } + + @Test + fun `an occurrence exactly at now is the current one`() { + val index = RecurrenceExpander.currentOccurrenceIndex(occurrences, at("2025-01-06T09:00:00Z")) + assertThat(index).isEqualTo(1) + } + + @Test + fun `past occurrences count down and later ones count up`() { + val distances = RecurrenceExpander.distancesFromCurrent(occurrences, at("2025-01-06T10:00:00Z")) + assertThat(distances).containsExactly(-2, -1, 0, 1).inOrder() + } + + @Test + fun `a series entirely in the future is current at its first occurrence`() { + val distances = RecurrenceExpander.distancesFromCurrent(occurrences, at("2024-12-01T00:00:00Z")) + assertThat(distances).containsExactly(0, 1, 2, 3).inOrder() + } + + @Test + fun `a series entirely in the past is current at its last occurrence`() { + val distances = RecurrenceExpander.distancesFromCurrent(occurrences, at("2026-01-01T00:00:00Z")) + assertThat(distances).containsExactly(-3, -2, -1, 0).inOrder() + } + + @Test + fun `exactly one occurrence is ever the current one`() { + val distances = RecurrenceExpander.distancesFromCurrent(occurrences, at("2025-01-07T00:00:00Z")) + assertThat(distances.count { it == 0 }).isEqualTo(1) + } + + @Test + fun `an empty series has no distances`() { + assertThat(RecurrenceExpander.distancesFromCurrent(emptyList(), at("2025-01-01T00:00:00Z"))).isEmpty() + assertThat(RecurrenceExpander.currentOccurrenceIndex(emptyList(), at("2025-01-01T00:00:00Z"))).isEqualTo(-1) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpanderTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpanderTest.kt new file mode 100644 index 0000000..bb98ffc --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpanderTest.kt @@ -0,0 +1,454 @@ +package de.jeanlucmakiola.agendula.domain.recurrence + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import java.time.ZoneId +import kotlin.time.Instant + +/** + * The provider only ever materialised the single next occurrence, so there is no + * provider behaviour to compare multi-occurrence expansion against. These cases + * assert against RFC 5545 §3.8.5 directly. + */ +class RecurrenceExpanderTest { + + private val berlin = "Europe/Berlin" + private val newYork = ZoneId.of("America/New_York") + + private fun at(text: String) = Instant.parse(text) + + private fun spec( + rrule: String? = null, + rdate: String? = null, + exdate: String? = null, + anchor: String, + isAllDay: Boolean = false, + timeZone: String? = berlin, + ) = RecurrenceSpec(rrule, rdate, exdate, at(anchor), isAllDay, timeZone) + + private fun window( + from: String = "2000-01-01T00:00:00Z", + until: String = "2100-01-01T00:00:00Z", + max: Int = 500, + ) = ExpansionWindow(at(from), at(until), max) + + private fun expand( + spec: RecurrenceSpec, + window: ExpansionWindow = window(), + floatingZone: ZoneId = newYork, + ) = RecurrenceExpander.expand(spec, window, floatingZone).map { it.toString() } + + // --- frequencies --------------------------------------------------------- + + @Test + fun `daily rule yields consecutive days at the same local time`() { + val result = expand(spec(rrule = "FREQ=DAILY;COUNT=3", anchor = "2025-01-07T08:00:00Z")) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-08T08:00:00Z", + "2025-01-09T08:00:00Z", + ).inOrder() + } + + @Test + fun `interval skips the intervening days`() { + val result = expand(spec(rrule = "FREQ=DAILY;INTERVAL=2;COUNT=3", anchor = "2025-01-07T08:00:00Z")) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-09T08:00:00Z", + "2025-01-11T08:00:00Z", + ).inOrder() + } + + @Test + fun `weekly by day expands to the named weekdays`() { + // The anchor is a Tuesday, which BYDAY=MO,WE,FR does not name. DTSTART is + // the first instance of the set regardless (RFC 5545 §3.8.5.3), so the + // Tuesday leads and the pattern takes over from there. + val result = expand( + spec(rrule = "FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=5", anchor = "2025-01-07T08:00:00Z"), + ) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", // Tue, the anchor + "2025-01-08T08:00:00Z", // Wed + "2025-01-10T08:00:00Z", // Fri + "2025-01-13T08:00:00Z", // Mon + "2025-01-15T08:00:00Z", // Wed + ).inOrder() + } + + @Test + fun `monthly by day expands to the nth weekday of the month`() { + // 2025-01-07 is the first Tuesday, so BYDAY=2TU lands on the 14th; the + // anchor still leads. + val result = expand(spec(rrule = "FREQ=MONTHLY;BYDAY=2TU;COUNT=3", anchor = "2025-01-07T08:00:00Z")) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-14T08:00:00Z", + "2025-02-11T08:00:00Z", + ).inOrder() + } + + @Test + fun `monthly by month day skips months that lack the day`() { + val result = expand(spec(rrule = "FREQ=MONTHLY;BYMONTHDAY=31;COUNT=3", anchor = "2025-01-31T08:00:00Z")) + assertThat(result).containsExactly( + "2025-01-31T08:00:00Z", + "2025-03-31T07:00:00Z", // February and April have no 31st; March is already CEST + "2025-05-31T07:00:00Z", + ).inOrder() + } + + @Test + fun `yearly rule repeats on the anniversary`() { + val result = expand(spec(rrule = "FREQ=YEARLY;COUNT=3", anchor = "2025-01-07T08:00:00Z")) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2026-01-07T08:00:00Z", + "2027-01-07T08:00:00Z", + ).inOrder() + } + + @Test + fun `yearly rule on a leap day only recurs in leap years`() { + val result = expand(spec(rrule = "FREQ=YEARLY;COUNT=3", anchor = "2024-02-29T08:00:00Z")) + assertThat(result).containsExactly( + "2024-02-29T08:00:00Z", + "2028-02-29T08:00:00Z", + "2032-02-29T08:00:00Z", + ).inOrder() + } + + // --- limits -------------------------------------------------------------- + + @Test + fun `COUNT limits the series`() { + val result = expand(spec(rrule = "FREQ=DAILY;COUNT=2", anchor = "2025-01-07T08:00:00Z")) + assertThat(result).hasSize(2) + } + + @Test + fun `UNTIL includes an occurrence falling exactly on it`() { + val result = expand( + spec(rrule = "FREQ=DAILY;UNTIL=20250109T080000Z", anchor = "2025-01-07T08:00:00Z"), + ) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-08T08:00:00Z", + "2025-01-09T08:00:00Z", + ).inOrder() + } + + @Test + fun `a floating UNTIL is read in the series zone`() { + // RFC 5545 §3.3.10 requires UNTIL in UTC when DTSTART carries a zone, and + // lib-recur throws outright on the mismatch. Stored rules break the rule + // anyway, so 09:00 floating has to mean 09:00 in Berlin. + val result = expand( + spec(rrule = "FREQ=DAILY;UNTIL=20250109T090000", anchor = "2025-01-07T08:00:00Z"), + ) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-08T08:00:00Z", + "2025-01-09T08:00:00Z", + ).inOrder() + } + + @Test + fun `an unbounded rule stops at the occurrence ceiling`() { + val result = expand( + spec(rrule = "FREQ=DAILY", anchor = "2025-01-07T08:00:00Z"), + window(max = 4), + ) + assertThat(result).hasSize(4) + assertThat(result.last()).isEqualTo("2025-01-10T08:00:00Z") + } + + @Test + fun `a sub-daily series spends most of the ceiling on occurrences from the pivot on`() { + // The real shape: an eight-hourly task, a year of window behind now and a + // 500-occurrence ceiling. Filling the budget from the window start would + // exhaust it ~166 days before now, so the task would never appear in Today + // or Upcoming at all. + val result = expand( + spec(rrule = "FREQ=HOURLY;INTERVAL=8", anchor = "2024-01-01T00:00:00Z"), + ExpansionWindow( + from = at("2024-06-01T00:00:00Z"), + until = at("2026-06-01T00:00:00Z"), + maxOccurrences = 500, + pivot = at("2025-06-01T00:00:00Z"), + ), + ) + assertThat(result).hasSize(500) + // A quarter of the budget looks back, and it keeps the *most recent* of + // the past — not the oldest, which is what filling from the window start + // would have kept. (ISO-8601 UTC sorts lexicographically.) + assertThat(result.count { it < "2025-06-01T00:00:00Z" }).isEqualTo(125) + assertThat(result.first()).isGreaterThan("2025-04-01T00:00:00Z") + assertThat(result.last()).isGreaterThan("2025-09-01T00:00:00Z") + } + + @Test + fun `the ceiling goes entirely to the future when nothing precedes the pivot`() { + val result = expand( + spec(rrule = "FREQ=DAILY", anchor = "2025-01-07T08:00:00Z"), + ExpansionWindow( + from = at("2025-01-01T00:00:00Z"), + until = at("2030-01-01T00:00:00Z"), + maxOccurrences = 4, + pivot = at("2025-01-01T00:00:00Z"), + ), + ) + assertThat(result).hasSize(4) + assertThat(result.last()).isEqualTo("2025-01-10T08:00:00Z") + } + + @Test + fun `an unbounded rule stops at the window end`() { + val result = expand( + spec(rrule = "FREQ=DAILY", anchor = "2025-01-07T08:00:00Z"), + window(until = "2025-01-10T00:00:00Z"), + ) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-08T08:00:00Z", + "2025-01-09T08:00:00Z", + ).inOrder() + } + + @Test + fun `the window end is exclusive`() { + val result = expand( + spec(rrule = "FREQ=DAILY", anchor = "2025-01-07T08:00:00Z"), + window(until = "2025-01-09T08:00:00Z"), + ) + assertThat(result).doesNotContain("2025-01-09T08:00:00Z") + } + + @Test + fun `occurrences before the window start are skipped`() { + val result = expand( + spec(rrule = "FREQ=DAILY", anchor = "2025-01-07T08:00:00Z"), + window(from = "2025-06-01T00:00:00Z", until = "2025-06-04T00:00:00Z"), + ) + assertThat(result).containsExactly( + "2025-06-01T07:00:00Z", + "2025-06-02T07:00:00Z", + "2025-06-03T07:00:00Z", + ).inOrder() + } + + // --- DST and all-day ----------------------------------------------------- + + @Test + fun `a daily series keeps its local time across a DST boundary`() { + // Europe/Berlin springs forward on 2025-03-30, so 09:00 local moves from + // 08:00Z to 07:00Z while the wall-clock time the user set stays put. + val result = expand(spec(rrule = "FREQ=DAILY;COUNT=4", anchor = "2025-03-28T08:00:00Z")) + assertThat(result).containsExactly( + "2025-03-28T08:00:00Z", + "2025-03-29T08:00:00Z", + "2025-03-30T07:00:00Z", + "2025-03-31T07:00:00Z", + ).inOrder() + } + + @Test + fun `an all-day series pins every occurrence to UTC midnight`() { + // Date-anchored, matching TaskWriteMapper's forAllDay: the stored zone and + // the device zone are both irrelevant, and no DST shift reaches it. + val result = expand( + spec( + rrule = "FREQ=DAILY;COUNT=3", + anchor = "2025-03-29T22:45:00Z", + isAllDay = true, + timeZone = berlin, + ), + ) + assertThat(result).containsExactly( + "2025-03-29T00:00:00Z", + "2025-03-30T00:00:00Z", + "2025-03-31T00:00:00Z", + ).inOrder() + } + + @Test + fun `an all-day weekly series stays on the same weekday`() { + val result = expand( + spec(rrule = "FREQ=WEEKLY;COUNT=3", anchor = "2025-01-15T00:00:00Z", isAllDay = true, timeZone = null), + ) + assertThat(result).containsExactly( + "2025-01-15T00:00:00Z", + "2025-01-22T00:00:00Z", + "2025-01-29T00:00:00Z", + ).inOrder() + } + + @Test + fun `a series with no zone expands in the floating zone`() { + val spec = spec(rrule = "FREQ=DAILY;COUNT=2", anchor = "2025-03-08T14:00:00Z", timeZone = null) + // 09:00 in New York, over the 2025-03-09 US DST switch. + assertThat(expand(spec, floatingZone = newYork)).containsExactly( + "2025-03-08T14:00:00Z", + "2025-03-09T13:00:00Z", + ).inOrder() + } + + // --- RDATE / EXDATE ------------------------------------------------------ + + @Test + fun `RDATE adds occurrences the rule does not produce`() { + val result = expand( + spec( + rrule = "FREQ=DAILY;COUNT=2", + rdate = "20250115T140000", + anchor = "2025-01-07T08:00:00Z", + ), + ) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-08T08:00:00Z", + "2025-01-15T13:00:00Z", // 14:00 Berlin + ).inOrder() + } + + @Test + fun `an RDATE before the anchor is part of the set`() { + val result = expand( + spec(rrule = "FREQ=DAILY;COUNT=2", rdate = "20250101T090000", anchor = "2025-01-07T08:00:00Z"), + ) + assertThat(result.first()).isEqualTo("2025-01-01T08:00:00Z") + } + + @Test + fun `an RDATE repeating a rule instance is not emitted twice`() { + val result = expand( + spec(rrule = "FREQ=DAILY;COUNT=3", rdate = "20250108T090000", anchor = "2025-01-07T08:00:00Z"), + ) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-08T08:00:00Z", + "2025-01-09T08:00:00Z", + ).inOrder() + } + + @Test + fun `multiple RDATEs are comma-separated`() { + val result = expand( + spec(rdate = "20250110T090000,20250112T090000", anchor = "2025-01-07T08:00:00Z"), + ) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-10T08:00:00Z", + "2025-01-12T08:00:00Z", + ).inOrder() + } + + @Test + fun `EXDATE removes an occurrence`() { + val result = expand( + spec(rrule = "FREQ=DAILY;COUNT=3", exdate = "20250108T090000", anchor = "2025-01-07T08:00:00Z"), + ) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-09T08:00:00Z", + ).inOrder() + } + + @Test + fun `EXDATE can remove the anchor itself`() { + val result = expand( + spec(rrule = "FREQ=DAILY;COUNT=3", exdate = "20250107T090000", anchor = "2025-01-07T08:00:00Z"), + ) + assertThat(result).containsExactly( + "2025-01-08T08:00:00Z", + "2025-01-09T08:00:00Z", + ).inOrder() + } + + @Test + fun `a UTC EXDATE matches a zoned occurrence at the same instant`() { + val result = expand( + spec(rrule = "FREQ=DAILY;COUNT=3", exdate = "20250108T080000Z", anchor = "2025-01-07T08:00:00Z"), + ) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-09T08:00:00Z", + ).inOrder() + } + + @Test + fun `an all-day EXDATE removes the matching date`() { + val result = expand( + spec( + rrule = "FREQ=DAILY;COUNT=3", + exdate = "20250116", + anchor = "2025-01-15T00:00:00Z", + isAllDay = true, + ), + ) + assertThat(result).containsExactly( + "2025-01-15T00:00:00Z", + "2025-01-17T00:00:00Z", + ).inOrder() + } + + // --- degenerate input ---------------------------------------------------- + + @Test + fun `a spec with no rule expands to just its anchor`() { + assertThat(expand(spec(anchor = "2025-01-07T08:00:00Z"))) + .containsExactly("2025-01-07T08:00:00Z") + } + + @Test + fun `a malformed rule degrades to the anchor instead of throwing`() { + assertThat(expand(spec(rrule = "FREQ=NONSENSE", anchor = "2025-01-07T08:00:00Z"))) + .containsExactly("2025-01-07T08:00:00Z") + } + + @Test + fun `a malformed RDATE is dropped and the rule still expands`() { + val result = expand( + spec(rrule = "FREQ=DAILY;COUNT=2", rdate = "not-a-date", anchor = "2025-01-07T08:00:00Z"), + ) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-08T08:00:00Z", + ).inOrder() + } + + @Test + fun `an unknown zone id falls back to the floating zone`() { + val result = expand( + spec(rrule = "FREQ=DAILY;COUNT=2", anchor = "2025-03-08T14:00:00Z", timeZone = "Mars/Olympus"), + floatingZone = newYork, + ) + assertThat(result).containsExactly( + "2025-03-08T14:00:00Z", + "2025-03-09T13:00:00Z", + ).inOrder() + } + + @Test + fun `a sub-second anchor does not double the first occurrence`() { + // RFC 5545 DATE-TIME has second precision, but a task created from + // Clock.now() carries millis. Left un-floored, lib-recur emits the raw + // anchor *and* its truncated self, so the series starts twice. + val result = expand(spec(rrule = "FREQ=DAILY;COUNT=3", anchor = "2025-01-07T08:00:00.081Z")) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-08T08:00:00Z", + "2025-01-09T08:00:00Z", + ).inOrder() + } + + @Test + fun `a window that ends before the anchor yields nothing`() { + val result = expand( + spec(rrule = "FREQ=DAILY", anchor = "2025-01-07T08:00:00Z"), + window(until = "2024-01-01T00:00:00Z"), + ) + assertThat(result).isEmpty() + } +} diff --git a/build.gradle.kts b/build.gradle.kts index a96f72d..d8237c8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,7 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.ksp) apply false alias(libs.plugins.hilt) apply false diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d7c562f..1d27981 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -8,23 +8,36 @@ This document describes how Agendula is built **as it stands today**. For the ## 1. The thesis in one sentence -Agendula is a Material 3 Expressive **front-end** over the OpenTasks -`TaskContract` provider — it reads, writes, and reminds on top of a tasks store -that some other app (DAVx5, SmoothSync, DecSync CC, tasks.org, …) syncs over -CalDAV. **Agendula owns no database and no sync stack.** It is the task-list -sibling to [Calendula](https://codeberg.org/jlmakiola/calendula), -which does the same thing for `CalendarContract`. +Agendula is a Material 3 Expressive task app that reads, writes and reminds +against a task store the user chooses: **its own database** (the default) or an +external provider app already on the device (OpenTasks, tasks.org) synced by +DAVx5, SmoothSync, DecSync CC and the like. It is the task-list sibling to +[Calendula](https://codeberg.org/jlmakiola/calendula), which does the same thing +for `CalendarContract`. + +**Agendula owns storage but not, yet, sync.** That is a deliberate change from +the original "owns no database" thesis, settled in +[`STORAGE-AND-SYNC.md`](STORAGE-AND-SYNC.md): depending on a provider app being +installed made someone else's roadmap a gate on the app working at all. The +store is a **Room database of our own**, designed against RFC 5545's `VTODO` and +against what a CalDAV sync adapter will need — the reasoning is in +[`STORAGE-DECISION.md`](STORAGE-DECISION.md), the architecture and the plan it +implements in [`OWN-STORE.md`](OWN-STORE.md). It replaces a vendored copy of the +dmfs task provider, which was deleted along with the `:provider` module it lived +in. External mode is untouched by that and still speaks the dmfs +`TaskContract`. A sync adapter of our own is the 1.x arc, designed in +[`SYNC.md`](SYNC.md). The whole design hangs off one rule: > The entire app talks to a `TasksRepository`. Only the data layer knows there -> is a `ContentResolver`, a `TaskContract`, or an authority string behind it. -> **Provider column names and the authority string never leak above the data -> layer.** +> is a Room database, a `ContentResolver`, a `TaskContract` or an authority +> string behind it. **Table and column names and the authority string never leak +> above the data layer.** -This is what lets "Posture A" (front-end over an installed provider) become -"Posture B" (bundle the Apache-2.0 provider, be self-contained) without touching -the UI, the ViewModels, or the domain. See §7. +That rule is what let the entire store be swapped without a rewrite: replacing +the provider with Room changed no UI, no ViewModel and exactly one domain field +(`Task.id` → `Task.occurrenceStart`, §4.8). See §7. --- @@ -38,30 +51,36 @@ the UI, the ViewModels, or the domain. See §7. │ domain models + Flows only ┌───────────────▼──────────────────────────────┐ Domain │ Models, TaskForm, TaskFilter, TaskSorting, │ - │ DayWindow (pure Kotlin, no Android) │ + │ TaskSections, RecurrenceExpander │ + │ (pure Kotlin, no Android) │ └───────────────┬──────────────────────────────┘ │ TasksRepository (interface) ┌───────────────▼──────────────────────────────┐ Data │ TasksRepositoryImpl │ │ └ TasksDataSource (interface) │ - │ └ AndroidTasksDataSource │ - │ └ ContentResolver / TaskContract / │ - │ ProviderResolver / ContentObserver│ + │ └ ModeRoutingTasksDataSource │ + │ ├ RoomTasksDataSource (OWN) │ + │ └ AndroidTasksDataSource (EXTERNAL)│ │ reminders/ prefs/ di/ demo/ │ - └───────────────┬──────────────────────────────┘ - │ content:// + dangerous perms - ┌───────────────▼──────────────────────────────┐ - External │ OpenTasks provider ←sync← DAVx5 / DecSync… │ - └──────────────────────────────────────────────┘ + └────────┬────────────────────────┬────────────┘ + │ Room DAOs │ content:// + ┌─────────────▼──────────┐ ┌──────────▼─────────────┐ + Sto- │ Own mode (default): │ │ External mode: │ + rage │ agendula-tasks.db — │ │ OpenTasks / tasks.org │ + │ four tables in our │ │ ←sync← DAVx5 / … │ + │ own data directory, │ │ dangerous perms, │ + │ nothing to permit │ │ asked at point of use │ + └────────────────────────┘ └────────────────────────┘ ``` The seam that matters is the pair of interfaces in the data layer: - **`TasksRepository`** — the only type the UI sees. Flow-based reads, suspend writes. (`data/tasks/TasksRepository.kt`) -- **`TasksDataSource`** — the JVM-testable interface that does the actual - provider work; `AndroidTasksDataSource` is the only Android-coupled - implementation. +- **`TasksDataSource`** — the JVM-testable, domain-shaped interface that does the + actual store work. Two implementations: `RoomTasksDataSource` for our own + store and `AndroidTasksDataSource` for an external provider, picked per call by + `ModeRoutingTasksDataSource` (§4.1). Both are bound in Hilt in `data/di/DataModule.kt`. @@ -69,84 +88,252 @@ Both are bound in Hilt in `data/di/DataModule.kt`. ## 3. Module & package layout -Single `:app` module (Posture A). Package root `de.jeanlucmakiola.agendula`. +One module, `:app`, plus the `floret-kit` included build (`includeBuild` in +`settings.gradle.kts`, consumed as `de.jeanlucmakiola.floret:*`). The +`:provider` module — the vendored dmfs task provider — was deleted with the own +store; `provider/PROVENANCE.md` went with it, its content preserved as a +postscript in [`STORAGE-DECISION.md`](STORAGE-DECISION.md). Package root +`de.jeanlucmakiola.agendula`. -| Package | Contents | +| Package (`:app`) | Contents | |---|---| -| `domain/` | `Models` (TaskList, Task, TaskDetail, enums + pure iCal↔domain value mappers), `TaskForm` (validated create/edit), `TaskFilter` + `TaskFiltering` (smart lists), `TaskSorting`, `DayWindow` (local-midnight maths). No Android imports. | -| `data/tasks/` | `TasksContract` (vendored subset), `ProviderResolver` (the A/B seam), `TaskProjections`, `ColumnReader`, `TaskMapper` (cursor→domain), `TaskWriteMapper` (form→`ContentValues`), `TasksDataSource` + `AndroidTasksDataSource`, `TasksRepository` + `Impl`, `Failures`. | +| `domain/` | `Models` (TaskList, Task, TaskDetail, enums + pure iCal↔domain value mappers), `TaskConstants` (status/priority/local-account constants), `TaskForm` (validated create/edit), `TaskFilter` + `TaskFiltering` (smart lists), `TaskSorting`, `TaskSections` (due-date sectioning), `AllDayTime` (the two date conventions). No Android imports; local-midnight maths comes from floret-kit's `DayWindow`. | +| `domain/recurrence/` | `RecurrenceExpander` — a stored rule set → its occurrences, over `lib-recur`. Pure Kotlin. | +| `domain/export/` | `ExportModels` + `ICalendarWriter` — VTODO serialization. Pure Kotlin, so the format is JVM-testable. | +| `data/tasks/` | `StorageMode` + `StorageModeHolder` + `ProviderResolver` + `ProviderEnvironment` (which store, §4.1), `ModeRoutingTasksDataSource`, `StartupGate`, `TasksDataSource`, `TasksRepository` + `Impl`, `Failures`; and the External-mode half — `TasksContract` (vendored subset), `TaskProjections`, `ColumnReader`, `TaskMapper` (cursor→domain), `TaskWriteMapper` (form→`ContentValues`), `AndroidTasksDataSource`. | +| `data/tasks/room/` | Agendula's own store: `Entities` (the four tables), a DAO per table, `TasksDatabase`, `Converters`, `RoomTasksDataSource`, `RoomTaskMapper` (row→domain), `TaskFormWriter` (form→entity), `DatabaseCheckpoint`. | +| `data/tasks/legacy/` | `OneShotImport` — a v0.3.x install's tasks out of the dmfs provider's file and into Room, once (§4.4). | +| `data/export/` | `TaskExporter` (lists → `.ics` documents), `ExportWriter` (SAF plumbing; a floret-kit candidate). | | `data/reminders/` | `ReminderScheduler` (the self-scheduled engine), `DueReminderReceiver`, `BootReceiver`, `ProviderChangeReceiver`, `ScheduledReminderStore`, `TaskNotifier`. | | `data/prefs/` | `SettingsPrefs` (DataStore). | -| `data/di/` | `DataModule` (binds + provides), `Qualifiers` (`@IoDispatcher`). | +| `data/di/` | `DataModule` (binds + provides), `Qualifiers` (`@IoDispatcher`, `@ApplicationScope`). | | `data/demo/` | `DemoSeeder` (debug-only sample data). | -| `ui/` | `theme/`, `common/` (GroupedList, ListChip), `lists/`, `tasklist/`, `detail/`, `edit/`, `settings/`, `permission/` (each a ViewModel + UiState; `lists` also has its screen), `RootScreen`. | +| `ui/` | `theme/`, `common/` (ListChip, PriorityChip, reminder pickers), `navigation/` (`AgendulaNavHost` + `Dest`), `lists/`, `tasklist/`, `detail/`, `edit/`, `settings/` (hub + sub-screens, `StorageScreen` among them), `export/`, `permission/` (each a screen + ViewModel + UiState), `crash/`, `RootScreen`. | | root | `AgendulaApp` (Hilt app), `MainActivity`. | --- ## 4. The data layer (the heart) -### 4.1 Provider targeting — `ProviderResolver` +### 4.1 Which store — `StorageMode` and `ProviderResolver` -`ProviderResolver.resolve()` walks a preference-ordered candidate list and -returns the first provider actually installed (via -`PackageManager.resolveContentProvider`), or `null` if none is. Each candidate -is a `TaskProvider(authority, readPermission, writePermission, packageName)`. +`StorageMode` has two values, `OWN` and `EXTERNAL`, and +`ModeRoutingTasksDataSource` picks the implementation **per call** — the mode is +a setting the user can change while the process lives, so binding it once would +mean rebuilding the object graph to honour a change. -| Provider | Authority | Permissions | -|---|---|---| -| OpenTasks | `org.dmfs.tasks` | `org.dmfs.permission.READ_TASKS` / `WRITE_TASKS` | -| tasks.org | `org.tasks.opentasks` | `org.tasks.permission.READ_TASKS` / `WRITE_TASKS` | +| Mode | Store | Authority | Permissions | +|---|---|---|---| +| **Own** (default) | our Room database, `agendula-tasks.db` | — none, it is not a provider | **none** | +| External | OpenTasks | `org.dmfs.tasks` | `org.dmfs.permission.READ_TASKS` / `WRITE_TASKS` | +| External | tasks.org | `org.tasks.opentasks` | `org.tasks.permission.READ_TASKS` / `WRITE_TASKS` | -Both are backed by the same dmfs `TaskProvider`, so the **same `TaskContract` -columns apply** regardless of which is present. `null` from `resolve()` drives -the "install a tasks provider" onboarding gate. `hasPermission()` checks both -runtime perms for the active provider. +`ProviderResolver` is now only about the second and third rows: it discovers the +*external* providers a device has. `resolve()` returns `null` in `OWN` mode — +there is no authority to resolve — and callers that need to tell that apart from +"External, and nothing installed" ask `mode()`. `TaskProvider` no longer carries +an `isOwn` flag; there is no own provider to flag. -### 4.2 `TasksContract` +`providerStatus()` is unconditionally `READY` in `OWN` mode. The permission gate +only ever applied to External, and that is now visibly true rather than a +same-uid special case inside `hasPermission()`. In External mode `null` from +`resolve()` is `NO_PROVIDER` and a missing runtime permission is +`NEEDS_PERMISSION`, which is what drives onboarding. + +**Choosing the mode.** An explicit choice is stored in `SettingsPrefs` and +mirrored into the resolver by `StorageModeHolder` — the resolver is consulted +synchronously on every query and cannot read DataStore itself. When there is no +explicit choice (the normal case), `autoMode()` decides: + +> **External if we already hold an external provider's runtime permission, +> otherwise Own.** + +That permission is dangerous-level, so it can only be there because an earlier +version asked and the user agreed — the signature of an existing Posture A user, +who must not be dropped onto an empty store and left to conclude their tasks were +deleted. A fresh install holds nothing and gets our own store. + +A stored `LOCAL` — the old name for the bundled provider — is read as `OWN` +(`SettingsPrefs.kt:104`) rather than as an unparseable value. Left to fall +through to `autoMode()`, someone who had explicitly chosen local storage *and* +holds an OpenTasks grant would be sent to OpenTasks, away from the data the +import just moved. + +The platform calls sit behind `ProviderEnvironment` so this decision is unit +tested on the JVM (`ProviderResolverTest`) rather than only on a device. + +**Synced is still not a third mode.** `STORAGE-AND-SYNC.md` describes three; a +synced list is `OWN` with an account attached, which is derived state rather than +something the user picks. Attaching one is a plain `UPDATE task_lists SET +account_id = ?` — `account_id` is a nullable FK from v1, so turning sync on for +an existing list is not a migration. (Under the dmfs provider it was: +`ACCOUNT_NAME`/`ACCOUNT_TYPE` were write-once, so tasks had to be moved into new +lists. That constraint left with the provider.) + +### 4.2 The own store — four Room tables + +`TasksDatabase` (v1, schema exported to `app/schemas/` and committed) holds +`task_lists`, `tasks`, `task_alarms` and `accounts`. The columns and the +reasoning behind each are in [`OWN-STORE.md`](OWN-STORE.md); what matters +structurally: + +- **`accounts` is empty until sync lands**, but the nullable `task_lists + .account_id` FK exists from v1 — that is what makes §4.1's "attaching an + account is an `UPDATE`" true. Deleting an account `SET NULL`s its lists rather + than deleting them. +- **Masters and `RECURRENCE-ID` overrides share the `tasks` table.** An override + is a row with `recurrence_id` set and `master_id` pointing at its master, + sharing the master's `uid`. The unique index is therefore + `(list_id, uid, recurrence_id)`; SQLite treats NULLs as distinct, so it + enforces the override half and states the master half as intent. +- **`uid` is `NOT NULL`**, minted at creation in either mode, synced or not — so + a task has a stable identity before a server ever sees it. +- `priority` is stored as the raw iCalendar integer (0 none, 1 highest, 9 + lowest). Bucketing into `Priority` on the way *in* would rewrite a server's + `PRIORITY:3` as `1`; the bucketing belongs to the mapper. +- Cascades: deleting a list takes its tasks, deleting a series takes its + overrides (`master_id`), deleting a parent promotes its subtasks + (`parent_id` → `SET NULL`). + +`RoomTaskMapper` maps a row to a domain `Task`; `TaskFormWriter` applies a +validated `TaskForm` to an entity. `TaskFormWriter` is the Room counterpart of +External mode's `TaskWriteMapper`, and deliberately not shared with it: most of +what that mapper does is work around the provider (clearing `DURATION` because +it validates a merged row; writing `STATUS` explicitly in both directions because +it auto-completes at 100% but will not reopen below it). Here the completion +rules are stated +directly — progress and status move together in both directions, so a task can +no longer strand itself "done at 75%". + +Deletes are hard when the list has no account and tombstones (`is_deleted`) when +it does — a row a server still knows about has to survive long enough to be +withdrawn from it. + +### 4.3 Recurrence — expanded at read, not materialised + +There is **no instances table**. The dmfs provider maintained one, recomputed on +every write, and still only ever materialised one upcoming occurrence. Agendula +expands a series in memory instead: + +``` +tasks (masters + overrides) ──► RecurrenceExpander ──► List + rrule/rdate/exdate (lib-recur, in memory) occurrences +``` + +This costs nothing because `TasksRepositoryImpl` already filters and sorts in +Kotlin, not SQL — nothing depended on the database ordering by instance time — +and the whole class of staleness bugs a cached table brings never exists. + +Expansion is bounded twice over: a window of **1 year back, 2 years forward** +(`RoomTasksDataSource.WINDOW_BACK`/`WINDOW_FORWARD`) and a hard per-series +occurrence ceiling, so an unbounded `RRULE` terminates. The iterator is +fast-forwarded to the window start first, so a `FREQ=MINUTELY` series anchored +years back does not scan millions of instances to emit one. A malformed +`RRULE`/`RDATE`/`EXDATE` is dropped rather than thrown: a task whose stored rule +cannot be parsed still has to appear. + +`RecurrenceExpander` returns each occurrence as its **`RECURRENCE-ID` anchor**. +`RoomTasksDataSource.occurrencesOf` then substitutes any override for the +occurrence it replaces; a timed series carries each occurrence's length across, +while a due-anchored one has no start to offset from, so the anchor *is* the due +date — matching how the provider instantiated the same series. + +`lib-recur` is pinned at **0.12.2** (0.16.0 removed `RecurrenceSet`) and is a +direct `:app` dependency now rather than something `:provider` dragged in. It is +still Apache-2.0 dmfs code, so the attribution is still owed — as a normal +third-party dependency. + +**Editing one occurrence writes a `RECURRENCE-ID` override** — RFC 5545's model +(a): a second `tasks` row with the master's `uid`, a `recurrence_id` naming the +occurrence, `master_id` pointing at the series, and the edit applied. The +provider's `Detaching.java` forked a brand-new task with its own UID instead +(model (d), the one least compatible with CalDAV); we inherited that without +choosing it, and this is the choice. + +### 4.4 Startup — the import and the gate + +`OneShotImport` moves a v0.3.x install's tasks out of the bundled provider's +`databases/tasks.db` and into Room, once. The file is opened read-only and +directly — no provider, no `ContentResolver` — so it keeps working now that +`:provider` is gone, and everything lands in one verified Room transaction, so a +failure leaves both Room and the source file exactly as they were. dmfs row ids +are remapped in two passes, because a parent can carry a higher `_id` than its +child, and `RECURRENCE-ID` overrides are carried across as `master_id` / +`recurrence_id` rather than imported as second masters, which would collide on +the unique index. The source is archived to `tasks.db.imported` **before** the +import, and the import always replaces: that ordering is what makes every kill +point re-enter correctly. + +`StartupGate` holds the first store read until the stored mode has reached +`ProviderResolver` *and* the import has run — `TasksRepositoryImpl.observing()` +awaits it before its first load, and `AgendulaApp` awaits it before the launch +reminder re-sync. Both are the same race: reading early answers from +`autoMode()` instead of the user's choice, or shows an upgrading user an empty +app. + +### 4.5 `TasksContract` — External mode only A vendored subset of the Apache-2.0 OpenTasks `TaskContract` — column names, table paths, status/priority constants, the local-account type. Agendula does **not** take a runtime dependency on OpenTasks; the authority is injected from -`ProviderResolver`, never hardcoded in the contract. +`ProviderResolver`, never hardcoded in the contract. Nothing in `OWN` mode +touches it: the domain's own status/priority/local-account constants live in +`domain/TaskConstants.kt`, so the domain layer never reaches into the data layer +to map its own enums. -### 4.3 Reads — Instances + `ContentObserver` +### 4.6 Reads and reactivity -`AndroidTasksDataSource` queries the denormalized **instances** view (so each -occurrence is a row with the joined list colour, account, etc.), maps each -cursor row through `ColumnReader` → `TaskMapper` → domain `Task`, and exposes -the result as a `Flow`. A `ContentObserver` on the active authority's -Tasks/TaskLists URIs bridges into the Flow via `callbackFlow`, so **any change -re-emits** — Agendula's own writes *and* external sync (DAVx5 pulling new tasks) -update the UI live, and multiple sync sources coexist in one list. +In `OWN` mode `RoomTasksDataSource` reads through the DAOs and expands +recurrences (§4.3). In `EXTERNAL` mode `AndroidTasksDataSource` queries the +denormalized **instances** view (so each occurrence is a row with the joined list +colour, account, etc.) and maps each cursor row through `ColumnReader` → +`TaskMapper` → domain `Task`. -### 4.4 Writes — repository API +`TasksDataSource.registerObserver(onChange): AutoCloseable` is unchanged and +backed differently on each side: Room's `InvalidationTracker` over the four +tables in `OWN` mode, a `ContentObserver` on the active authority's +Tasks/TaskLists URIs in `EXTERNAL`. Either way `TasksRepositoryImpl.observing()` +bridges it into a `Flow` via `callbackFlow`, so **any change re-emits** — +Agendula's own writes *and*, in External mode, DAVx5 pulling new tasks. + +### 4.7 Writes — repository API ```kotlin interface TasksRepository { fun taskLists(): Flow> fun tasks(filter: TaskFilter): Flow> + fun subtasks(parentId: Long): Flow> fun taskDetail(taskId: Long): Flow suspend fun createTask(form: TaskForm): Long - suspend fun updateTask(taskId: Long, form: TaskForm) + suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant? = null) suspend fun setCompleted(taskId: Long, completed: Boolean) // the core gesture suspend fun deleteTask(taskId: Long) + suspend fun reminderFor(taskId: Long): Int? suspend fun createLocalList(name: String, color: Int): Long fun providerStatus(): ProviderStatus // READY | NEEDS_PERMISSION | NO_PROVIDER } ``` -`TaskWriteMapper` turns a validated `TaskForm` into `ContentValues`. Completion -sets `STATUS = COMPLETED` (+ percent/completed timestamp); DAVx5 syncs that back -out as a normal VTODO status change. Writes to local/unsynced lists use the -sync-adapter URI form where the provider requires it. +`updateTask` on an occurrence of a recurring task routes to +`TasksDataSource.updateInstance(taskId, occurrenceStart, form)` rather than +moving the series anchor. `expectedLastModified` re-checks the stored timestamp +first and throws `TaskConflictException` when something changed underneath the +form. -### 4.5 Domain model notes +Completion sets `STATUS = COMPLETED` (+ percent/completed timestamp); in +External mode DAVx5 syncs that back out as a normal VTODO status change. -- `Task.id` is the **instance** row id; `Task.taskId` is the underlying - `tasks._id` and the stable target for edits/completion. +### 4.8 Domain model notes + +- `Task.taskId` is the task row and the stable target for edits, completion and + navigation. There is no `Task.id` any more — it was the materialised instance + row id, and materialised instances are gone. +- `Task.occurrenceStart` is the occurrence's `RECURRENCE-ID` anchor, `null` for a + non-recurring task; `Task.occurrenceKey` (`"$taskId@$millis"`) is what lazy + lists key by. Two occurrences of one series can appear in the same list, so + `taskId` alone would collide there — as a Compose key that is a visible bug. - Subtasks are carried via `parentId` (`RELATED-TO` / `RELATION_TYPE_PARENT`); `TaskDetail` bundles a task with its direct children. - `effectiveColor` = the task's own colour, else the list colour. @@ -160,7 +347,7 @@ sync-adapter URI form where the provider requires it. `TaskFilter` is either `OfList(listId)` or `Smart(SmartList)`. The smart lists — `ALL, TODAY, UPCOMING, OVERDUE, NO_DATE, COMPLETED` — are computed from due dates, not membership. `TaskFiltering.matches()` is a **pure predicate** taking -`todayStart`/`todayEnd` (local-midnight bounds from `DayWindow`), so it +`todayStart`/`todayEnd` (local-midnight bounds from floret-kit's `DayWindow`), so it unit-tests with a fixed clock. `TaskSorting` orders within a list (due / priority / etc.). None of this touches Android, which is why it's all in `domain/`. @@ -181,10 +368,24 @@ providers broadcast nothing**, so Agendula schedules its own (`data/reminders/`) `set` when `canScheduleExactAlarms()` is false), keyed by `taskId`. - **`DueReminderReceiver`** fires → posts via `TaskNotifier` (channel, `POST_NOTIFICATIONS` gate, dedupe-by-tag). -- **Re-sync triggers:** app start, **`BootReceiver`** (re-arm after reboot), and - **`ProviderChangeReceiver`** (`PROVIDER_CHANGED` on both authorities → external - sync changed the data). The store lets each run diff like Calendula diffs - reminder rows. +- **Re-sync triggers:** app start (after `StartupGate`), **`BootReceiver`** + (re-arm after reboot), and **`ProviderChangeReceiver`**. The store lets each + run diff like Calendula diffs reminder rows. + +`sync()` gates on **`ProviderResolver.canReadStore()`**, not on a provider +resolving. `OWN` is always readable; only `EXTERNAL` can fail, and only for the +two reasons it ever could (nothing installed, or no grant). Gating on +`resolve() != null` — as it did briefly — clears every alarm the moment `OWN` is +active, because `OWN` resolves to no provider by design. `ProviderResolverTest` +covers both directions. + +`ProviderChangeReceiver`'s manifest filter now lists **only the two external +authorities**: Agendula publishes no provider and broadcasts no +`ACTION_PROVIDER_CHANGED`, so there is nothing of ours to listen for. In `OWN` +mode Room's `InvalidationTracker` covers foreground changes and nothing outside +the app can change our data. When `SYNC.md` phase 3 lands, the sync worker calls +`ReminderScheduler.sync()` itself — that is the replacement for the broadcast, +and it belongs in the sync work. This is the single largest piece of genuinely-new code in Agendula. @@ -192,17 +393,46 @@ This is the single largest piece of genuinely-new code in Agendula. ## 7. The A / B seam (why the layering is shaped this way) -- **Posture A (today):** front-end over whatever provider is installed. Ships - fast; requires a provider app present (the "needs DAVx5/OpenTasks" onboarding - moment). -- **Posture B (later):** add a `:provider` module bundling the Apache-2.0 - `opentasks-provider`. `ProviderResolver` then finds **our own** `org.dmfs.tasks` - first; external CalDAV engines sync directly into it. **The UI, ViewModels, - domain, and `TasksRepository` do not change** — only the resolver's default and - some manifest perms. +Both terms were **redefined** by [`STORAGE-AND-SYNC.md`](STORAGE-AND-SYNC.md). +They no longer mean what earlier drafts of this document said. -Bundling the provider bundles **storage, not sync** — Agendula stays a pure -front-end over open backends either way. +- **Posture A** — front-end over an *external* provider (OpenTasks, tasks.org). + Still fully supported; it stopped being the only option and became a user + choice, `StorageMode.EXTERNAL`. +- **Posture B (shipped, then rebuilt)** — a store of our own, `StorageMode.OWN`. + It first shipped as the `:provider` module, the Apache-2.0 dmfs provider + vendored under our own authority; it is now a Room database and the module is + deleted. What made the vendored provider worth keeping was the sync bookkeeping + it appeared to hand us for free, and the phase-1 sync audit measured that + bookkeeping and found most of it broken, absent or unusable — the argument and + the costing are in [`STORAGE-DECISION.md`](STORAGE-DECISION.md). + +> **Dead end, do not revisit:** publishing a task provider under dmfs's *own* +> authority so DAVx5 would sync into it unwittingly. Two apps cannot declare the +> same authority (`INSTALL_FAILED_CONFLICTING_PROVIDER`) or the same +> `` name (`INSTALL_FAILED_DUPLICATE_PERMISSION`), so anyone with +> OpenTasks installed simply could not have installed Agendula. Account +> visibility is also keyed by *package*, not authority, which would have left such +> a provider seeing zero accounts and pruning synced lists as orphaned. Full +> reasoning in `STORAGE-AND-SYNC.md`. + +The seam earned its keep twice over. `ProviderResolver` is still the only thing +that knows an authority exists and `AndroidTasksDataSource` the only thing that +touches a resolver, so vendoring an entire content provider changed nothing above +the data layer — and **replacing** it with a database of our own changed no UI, +no ViewModel and exactly one domain field (`Task.id` → `occurrenceStart`), which +was forced by dropping materialised instances rather than by the store swap +itself. + +⚠️ **The authority is gone, and that is a breaking change.** +`de.jeanlucmakiola.agendula.tasks` and both custom permissions no longer exist, +so anyone who had pointed DAVx5 or another app at that authority loses it. +External mode is the answer for them; it needs saying in the release notes. + +Owning the store owns **storage, not sync**. Our own sync adapter is a separate, +later piece of work — designed in [`SYNC.md`](SYNC.md), and it lands *underneath* +this same seam: it writes through the DAOs, so the layers above stay untouched a +third time. --- @@ -216,21 +446,38 @@ fallback in `ui/theme/`). Each screen area (`lists`, `tasklist`, `detail`, `RootScreen` is the entry composable: it gates on `ProviderStatus` (`NO_PROVIDER` / `NEEDS_PERMISSION` → onboarding `Gate`; `READY` → -`ListsScreen`). The remaining screens are being built one at a time — their -ViewModels exist and are tested against the real data layer; navigation -callbacks are currently stubs (see [`ROADMAP.md`](ROADMAP.md)). Follow the -`material-3` skill for component choices (M3 `ListItem` rows, expressive -checkbox/FAB/swipe motion). +`AgendulaNavHost`). In `OWN` mode the status is always `READY`, so that gate is +only ever seen in External mode — and it offers a way back to our own store, +because it is the only screen an External user can reach once their provider app +stops answering. Routes are the `Dest` table in `ui/navigation/` (lists → task +list → detail / edit, plus settings). Follow the `material-3` skill for component +choices (M3 `ListItem` rows, expressive checkbox/FAB/swipe motion). + +Settings is a hub of sliding sub-screens rather than routes; **Storage** is the +one with teeth. It holds the §4.1 store picker — which asks for an external +provider's runtime permission *before* writing the mode, so a denial leaves the +readable store in place instead of stranding the user on the gate — and the +export screen (`ui/export/`, one `.ics` per ticked list, written through SAF to a +folder or a single zip). Because the mode is now switchable while the process +lives, `AgendulaApp` re-arms reminders on `ProviderResolver.onModeChanged`: an +alarm is scheduled off whichever store was active at the time, so the whole set +has to be rebuilt against the new one. --- ## 9. Dependency injection -Hilt, `SingletonComponent`. `DataModule` has a `@Binds` module -(`TasksDataSource` → `AndroidTasksDataSource`, `TasksRepository` → -`TasksRepositoryImpl`) and a `@Provides` module (the `agendula_prefs` DataStore, -the `@IoDispatcher`). `AgendulaApp` is the `@HiltAndroidApp` entry point; -`MainActivity` is `@AndroidEntryPoint`. ViewModels get the repository injected. +Hilt, `SingletonComponent`. `DataModule` has a `@Binds` module (`TasksRepository` +→ `TasksRepositoryImpl`, `ProviderEnvironment` → `AndroidProviderEnvironment`) +and a `@Provides` module (the `agendula_prefs` DataStore, `TasksDatabase`, the +`@IoDispatcher`, the `@ApplicationScope`). `TasksDataSource` is `@Provides` +rather than `@Binds`, because it is a `ModeRoutingTasksDataSource` over +`Provider` and `Provider` — both +singletons, so it picks between two long-lived objects rather than building +either. `AgendulaApp` is the `@HiltAndroidApp` entry point and pulls +`StartupGate`, `DatabaseCheckpoint` and `ReminderScheduler` through an +`@EntryPoint`; `MainActivity` is `@AndroidEntryPoint`. ViewModels get the +repository injected. --- @@ -241,8 +488,9 @@ the `@IoDispatcher`). `AgendulaApp` is the `@HiltAndroidApp` entry point; | Build | AGP 9.2.1, Kotlin 2.3.21, KSP, Hilt 2.59.2, Java 17 | | SDK | compileSdk 37, minSdk 29 (Android 10), targetSdk 36 | | UI | Compose BOM 2026.05.01, Material3 `1.5.0-alpha21` (Expressive APIs), Glance 1.1.1 (widget, later) | -| Other | DataStore, kotlinx-datetime, kotlinx-coroutines | -| Tests | JUnit5 (Jupiter) + Truth + Turbine + coroutines-test; the data source is the JVM-testable seam | +| Store | Room 2.8.4 (KSP, `room.schemaLocation = app/schemas`, WAL), `org.dmfs:lib-recur` 0.12.2 pinned (0.16.0 removed `RecurrenceSet`; `rfc5545-datetime` arrives with it as part of its API surface) | +| Other | DataStore, DocumentFile (SAF export), kotlinx-datetime, kotlinx-coroutines, the `floret-kit` included build | +| Tests | JVM: JUnit5 (Jupiter) + Truth + Turbine + coroutines-test, with the data source, `ProviderEnvironment`, `TaskFormWriter` and `RecurrenceExpander` as the JVM-testable seams. Instrumented (`app/src/androidTest`, AndroidJUnitRunner + Truth): the Room schema, `RoomTasksDataSource` and `OneShotImport` — the last against `assets/tasks-v23.db`, a fixture written by `scripts/make_import_fixture.py` in the provider's DATABASE_VERSION 23 schema, since the provider it came from no longer exists to test against. | | Versioning | committed `versionName` is the source of truth; a bump reaching `main` triggers the release and the pipeline mints the `vX.Y.Z` tag. `versionCode = MAJOR*10000 + MINOR*100 + PATCH`. See [`RELEASING.md`](RELEASING.md). | | CI | Split by forge: `.forgejo/workflows/ci.yaml` on Codeberg (canonical, no secrets), `.gitea/workflows/release.yaml` on Gitea (all secrets). See [`RELEASING.md`](RELEASING.md). | | Distribution | F-Droid (`fdroid-metadata/`) + Codeberg release APKs | @@ -252,12 +500,27 @@ the `@IoDispatcher`). `AgendulaApp` is the `@HiltAndroidApp` entry point; ## 11. Manifest surface - **Permissions:** both `org.dmfs.*` and `org.tasks.*` read/write tasks perms - declared statically (the active set is requested at runtime); - `POST_NOTIFICATIONS`, `RECEIVE_BOOT_COMPLETED`, exact-alarm - (`USE_EXACT_ALARM` on 33+, `SCHEDULE_EXACT_ALARM` ≤32). -- **``** for package visibility: both provider authorities + a LAUNCHER - intent (so `resolveContentProvider` works and onboarding can open the - provider / a store listing). -- **Receivers:** `DueReminderReceiver` (not exported), `BootReceiver`, - `ProviderChangeReceiver` (both authorities). No `EVENT_REMINDER` receiver — - that's a Calendula thing that doesn't apply here. + (static manifest, so they are always declared; requested at runtime only in + External mode); `POST_NOTIFICATIONS`, `RECEIVE_BOOT_COMPLETED`, exact-alarm + (`USE_EXACT_ALARM` on 33+, `SCHEDULE_EXACT_ALARM` ≤32). `OWN` mode needs + nothing here at all: it is a database in our own data directory. +- **No `` and no custom permissions.** Agendula publishes no content + provider; `de.jeanlucmakiola.agendula.tasks`, the + `de.jeanlucmakiola.agendula.permission.*` pair and their permission group all + went with the `:provider` module. +- **Deliberately absent:** `GET_ACCOUNTS`, and `INTERNET`, which stays undeclared + until sync actually ships. Export needs no storage permission at all; SAF hands + us a `Uri` the user picked. +- **``** for package visibility: both external provider authorities + + a LAUNCHER intent (so `resolveContentProvider` works and onboarding can open + the provider / a store listing). +- **Receivers:** `DueReminderReceiver` (not exported), `BootReceiver`, and + `ProviderChangeReceiver` — the latter filtering on the two *external* + authorities only (an intent-filter host must be a literal). No `EVENT_REMINDER` + receiver — that's a Calendula thing that doesn't apply here. +- **Backup:** `agendula-tasks.db` plus its `-wal` and `-shm` sidecars are + included in both `backup_rules.xml` and `data_extraction_rules.xml`; + `tasks.db.imported` is excluded, since it is a copy of data already imported. + Room runs in WAL mode and Auto Backup copies files without checkpointing, so + `DatabaseCheckpoint` runs `PRAGMA wal_checkpoint(TRUNCATE)` on `ON_STOP` to + keep the `.db` alone current for a restore that drops the sidecars. diff --git a/docs/OWN-STORE.md b/docs/OWN-STORE.md new file mode 100644 index 0000000..48a9db9 --- /dev/null +++ b/docs/OWN-STORE.md @@ -0,0 +1,621 @@ +# Agendula's own task store — architecture and plan + +**Branch:** `feat/own-store` +**Decision:** taken. `docs/STORAGE-DECISION.md` costed it; this is the build. +**Supersedes:** the "keep `:provider`" position in `STORAGE-AND-SYNC.md` and the +"Settled" section of `SYNC.md`. + +--- + +## The decision, in one paragraph + +Agendula stops vendoring the dmfs OpenTasks provider. The `:provider` module — +14,555 lines of Java, 1.66× the size of the app itself — is **deleted**. In its +place the app gets its own Room database, designed for the two things Agendula +actually does: show tasks, and sync them over CalDAV. Support for *external* +providers (OpenTasks, tasks.org) **stays**, unchanged, as a user choice — so +anyone already syncing through DAVx5 keeps working exactly as they do today. + +Agendula becomes a normal Android app with a normal database, plus an optional +compatibility path into somebody else's ContentProvider. + +--- + +## What changes and what does not + +``` +BEFORE AFTER + + UI / ViewModels UI / ViewModels + │ │ + TasksRepository TasksRepository ← unchanged + │ │ + TasksDataSource (interface) TasksDataSource ← one method + │ ╱ ╲ changes + AndroidTasksDataSource RoomTasksDataSource AndroidTasksDataSource + │ │ │ + ContentResolver Room / SQLite ContentResolver + │ │ │ + ┌────┴─────┐ our tables ┌──────┴──────┐ + │ │ │ │ +:provider OpenTasks OpenTasks tasks.org +(deleted) tasks.org (external, unchanged) +``` + +**Unchanged above the data layer.** Every ViewModel, every screen. Verified: +exactly one file outside `data/tasks` references `TasksContract` +(`domain/Models.kt`, for five constants), and it stops doing so in phase 0. +Navigation addresses tasks by `taskId` throughout (`Destinations.kt:59`, +`TaskDetailScreen.kt:202,384`) — never by the instance id — so the change below +does not reach the UI. + +**One seam method changes.** `TasksDataSource.updateInstance(instanceId, form)` +becomes `updateInstance(taskId, occurrenceStart, form)`, and `Task` gains +`occurrenceStart: Instant?`. See *Instance identity* — this is the one place the +"nothing above the data layer changes" claim needed qualifying, and +`TasksRepositoryImpl.updateTask` is the only caller. + +**Deleted.** The `:provider` Gradle module, its manifest ``, its two +custom permissions, its 84 Java files, its 13 translated string resources, and +its three dmfs runtime dependencies from `:provider`'s own build file. + +**Kept for External mode.** `TasksContract.kt`, `ColumnReader.kt`, +`TaskMapper.kt`, `TaskWriteMapper.kt`, `AndroidTasksDataSource.kt`, +`ProviderResolver.kt`, `ProviderEnvironment.kt`, `TaskProjections.kt`. These +describe *somebody else's* schema and are exactly right for that job. + +--- + +## Storage modes after the change + +```kotlin +enum class StorageMode { + /** Agendula's own Room database. The default; always available. */ + OWN, + /** A tasks provider app already installed — OpenTasks, tasks.org. */ + EXTERNAL, +} +``` + +`LOCAL` (meaning "our bundled dmfs provider") is gone. `ProviderResolver.own` +and the `TaskProvider(isOwn = true)` case go with it: in `OWN` mode there is no +authority, no ContentResolver and no permission to grant. + +> **This rename happens in phase 5, not phase 0.** Between phases 1 and 4 both +> stores exist, so the enum carries `LOCAL` (dmfs), `OWN` (Room) and `EXTERNAL` +> simultaneously. Renaming `LOCAL` → `OWN` up front would make `OWN` mean *dmfs* +> for four phases and *Room* afterwards, which is exactly the kind of thing that +> gets misread six weeks later. + +`ProviderResolver` narrows to what it was always really for — **discovering +external providers** — and `ProviderStatus.READY` becomes unconditional in `OWN` +mode. + +### Consequences worth stating plainly + +- **No runtime permission is needed for the default path.** Today's permission + prompt only ever applied to External mode; now that is visibly true. +- **Third-party apps can no longer read Agendula's tasks.** We publish no + ContentProvider. Users who need interop pick External mode, or wait for a + possible read-only facade (explicitly out of scope — see *Deliberately not + doing*). +- **Local lists still report an account name.** `TaskList.accountName` is a + non-null String that `ListsViewModel.kt:98` groups by and + `ListsScreen.kt:222` renders as a section header. `RoomTasksDataSource` maps + `account_id IS NULL` to `accountName = "Local"`, `accountType = + "local"`, so the existing grouping and `TaskList.isLocal` keep working with no + UI change. (`isLocal` moves off `TasksContract.LOCAL_ACCOUNT_TYPE` in phase 0 + and compares against a `domain` constant instead.) + + Knock-on, benign: `TaskEditViewModel.kt:101` picks the first *non*-local list + as the edit form's default. In `OWN` mode with no account configured every + list is local, so it falls through to `firstOrNull()`. Same practical result, + worth knowing before someone reports it as a bug. +- **Auto Backup gets simpler and safer.** One Room file we control, with a + documented restore path, instead of a provider database whose `cleanUpLists` + routine could delete restored lists whose accounts no longer exist. + + ⚠️ With one caveat that has to be handled, not assumed away: **Room enables + write-ahead logging by default**, and Auto Backup copies files without + checkpointing. A `-wal` sidecar can hold writes the backed-up `.db` does not. + We checkpoint (`PRAGMA wal_checkpoint(TRUNCATE)`) on `ON_STOP` and include + `.db`, `-wal` and `-shm` together in the backup rules, so a restore is + consistent either way. Phase 6 tests this, because "our backup is safer" is + the kind of claim that is worth exactly as much as its test. + +--- + +## The schema + +Four tables. Designed from Agendula's actual reads and writes plus RFC 5545's +`VTODO`, not inherited from a 2013 schema. + +### `task_lists` + +| Column | Type | Notes | +|---|---|---| +| `id` | INTEGER PK | | +| `name` | TEXT NOT NULL | | +| `color` | INTEGER NOT NULL | ARGB | +| `account_id` | INTEGER NULL | FK → `accounts`, NULL = device-only | +| `is_visible` | INTEGER NOT NULL | default 1 | +| `is_synced` | INTEGER NOT NULL | default 1 | +| `owner` | TEXT NULL | CalDAV owner display name | +| `is_read_only` | INTEGER NOT NULL | **new** — the provider could not express this at all | +| `sort_order` | INTEGER NOT NULL | user ordering, which the provider also lacked | +| `href` | TEXT NULL | collection URL, relative to the account root | +| `ctag` | TEXT NULL | | +| `sync_token` | TEXT NULL | RFC 6578, **per collection** — not squatted into a shared slot | +| `is_dirty` | INTEGER NOT NULL | a real boolean, not dmfs's monotonic counter | + +> `account_id` being nullable is the single most important schema change. In the +> dmfs provider `ACCOUNT_TYPE` is write-once and throws on change, which made +> "turn sync on" a full data migration. Here, attaching a local list to an +> account is `UPDATE task_lists SET account_id = ?`. + +### `tasks` + +Master rows *and* recurrence overrides live here. An override is a row with +`recurrence_id` set and `master_id` pointing at its series master. + +> `master_id` and `parent_id` are different things and must not be conflated. +> **`parent_id`** is task hierarchy — a subtask's parent, the thing +> `RELATED-TO;RELTYPE=PARENT` carries. **`master_id`** is recurrence — which +> series an override belongs to. A row can have both: a subtask can itself +> recur. + +| Group | Columns | +|---|---| +| identity | `id`, `list_id`, `uid` (NOT NULL, minted at creation), `href`, `etag` | +| content | `title`, `description`, `location`, `url`, `color` | +| state | `status`, `percent_complete`, `completed_at`, `priority`, `classification` | +| time | `dtstart`, `due`, `duration`, `is_all_day`, `timezone` | +| recurrence | `rrule`, `rdate`, `exdate`, `recurrence_id`, `master_id` | +| hierarchy | `parent_id`, `sort_order` | +| audit | `created_at`, `last_modified`, `sequence` | +| sync | `is_dirty`, `is_deleted`, `unknown_properties` | + +Two entries deserve explanation. + +**`uid` is NOT NULL and assigned at creation.** Every task gets a real +RFC 4122 UUID the moment it is inserted, in every mode, synced or not. This +closes the gap `ICalendarWriter.uidFor` currently papers over by synthesising +`agendula-@…`, and it means any local task can later be pushed to a +server without duplicating. The provider never assigned one. + +**`unknown_properties`** holds the raw unfolded iCalendar lines of every +property we do not model — `ATTENDEE`, `CATEGORIES`, `X-*`, `GEO`, and anything +a future RFC adds. On write we re-emit them verbatim after the properties we do +own. This is what makes an honest round-trip possible, and it replaces the +provider's `data0`–`data15` bag with something that cannot silently lose a field +it has no column for. + +Indices: `(list_id, is_deleted)`, `(parent_id)`, `(master_id, recurrence_id)`, +`(is_dirty)`, and **unique on `(list_id, uid, recurrence_id)`**. + +> The unique index deliberately includes `recurrence_id`. An override **shares +> its master's UID** — that is what makes it an override rather than a separate +> task — so a unique index on `(list_id, uid)` alone would reject the very rows +> the recurrence design depends on. With `recurrence_id` NULL on the master and +> set on each override, the constraint says the right thing: one master and at +> most one override per occurrence, per UID, per list. + +**Cascades.** `master_id` is `ON DELETE CASCADE` — deleting a series deletes its +overrides, which would otherwise become unreachable rows that still sync. +`parent_id` is `ON DELETE SET NULL`: deleting a parent promotes its subtasks to +top level rather than destroying work the user did not ask to lose. `task_id` on +`task_alarms` cascades. + +**Type converters.** Every time column is `kotlin.time.Instant` in the entity +and INTEGER epoch-millis in SQLite, via one `@TypeConverter` pair. `status` and +`priority` convert through the existing `domain` enums, so `statusFromInt` / +`toInt()` keep their single home. + +### `task_alarms` + +| Column | Notes | +|---|---| +| `id`, `task_id` | FK, `ON DELETE CASCADE` | +| `minutes_before` | positive = before the reference | +| `reference` | `DUE` or `START` | +| `message` | optional | + +Replaces `AlarmHandler` (133 lines of Java) and the `dataN` slot convention. +Delete-and-reinsert stops being necessary — the provider's re-validate-everything +behaviour was the only reason `setAlarm` worked that way. + +### `accounts` + +| Column | Notes | +|---|---| +| `id`, `display_name`, `principal_url`, `home_set_url` | +| `username` | the app password is **not** here — Keystore only, per `SYNC.md` | +| `last_sync_at`, `last_sync_error` | + +Not populated until `SYNC.md` phase 2, but the FK exists from v1 so enabling +sync never requires a schema migration. + +--- + +## Recurrence: expand at read, not on write + +The provider maintained a materialised `instances` table, recomputed by +`Instantiating.java` on every write — and still only ever materialised **one** +upcoming occurrence. + +Agendula expands lazily instead: + +``` +tasks (masters + overrides) ──► RecurrenceExpander ──► List + rrule/rdate/exdate (lib-recur, in memory) occurrences +``` + +This is the right call here because **the repository already filters and sorts +in Kotlin, not SQL**. `TasksRepositoryImpl.loadTasks` reads the whole set, +applies `TaskFiltering.matches`, then `TaskSorting.DEFAULT`. Nothing depends on +the database being able to order by instance time, so nothing is lost — and a +materialised table's entire class of staleness bugs never exists. + +- Bounded window: expansion is capped (default: 1 year back, 2 years forward, + hard ceiling of N occurrences per series) so an unbounded `RRULE` cannot hang + the UI. +- `lib-recur` **pinned at 0.12.2** — 0.16.0 removed `RecurrenceSet`. We pin + because we chose to, and the pin is now ours to lift on our own schedule. +- Client-side expansion is required for CalDAV regardless: server-side + `CALDAV:expand` on `VTODO` is broken on every server `SYNC.md` targets. + +### Instance identity + +Deleting the materialised `instances` table deletes the instance **row id**, and +two things use it today: + +- `TasksRepositoryImpl.updateTask` → `dataSource.updateInstance(current.id, …)` +- `ListsScreen.kt:422` → `items(results, key = { it.id })` + +The Compose key is the constraint that decides the design. Two occurrences of +one series can appear in the same list, so `taskId` alone is not unique, and a +hash of `(taskId, start)` folded into a `Long` can collide — which as a Compose +key is a visible bug, not a theoretical one. + +So we address occurrences by what they actually are: + +```kotlin +data class Task( + val taskId: Long, // the master row — unchanged, what navigation uses + val occurrenceStart: Instant?, // null for a non-recurring task + … +) + +fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) +``` + +`Task.id` is dropped; the Compose key becomes `"$taskId@${occurrenceStart}"`, +which is unique by construction and stable across reloads. + +**External mode absorbs this without loss.** `AndroidTasksDataSource` maps +`(taskId, occurrenceStart)` back to a real instance row with one query — +`WHERE task_id = ? AND instance_start = ?` — before writing through the +instances URI. One extra query on an operation the user performs by hand, in +exchange for a seam that does not depend on a foreign table's row ids. + +This is the **only** change to `TasksDataSource`, and +`TasksRepositoryImpl.updateTask` is its only caller. + +### Completing one occurrence of a recurring task + +The provider's `Detaching.java` implemented **model (d): detach the occurrence +as a brand-new task with its own UID**. We inherited that without ever choosing +it, and it is the model least compatible with CalDAV. + +**We implement model (a): a `RECURRENCE-ID` override.** Completing one +occurrence writes a second `tasks` row with the same `uid`, a `recurrence_id` +naming the occurrence, `master_id` pointing at the series, and the completed +state. This is what RFC 5545 specifies and what every other CalDAV client +expects to receive. + +This decision is now made explicitly, recorded here, and testable. + +--- + +## Reactivity + +`TasksDataSource.registerObserver(onChange: () -> Unit): AutoCloseable` **stays +as-is**. The Room implementation backs it with `InvalidationTracker.Observer` +over the four tables; the External implementation keeps its `ContentObserver`. +One interface, two mechanisms, `TasksRepositoryImpl.observing()` untouched. + +Going Flow-native in the DAOs is a later, optional refinement. Doing it now +would change the interface and therefore the External path, for no user-visible +gain. + +--- + +## Migrating existing users + +Anyone on v0.3.x has their tasks inside the bundled provider's SQLite file at +`/data/data/de.jeanlucmakiola.agendula/databases/tasks.db` (dmfs schema +version 23). Removing the Gradle module does **not** remove that file — an app +update leaves the data directory intact. + +So the migration reads the file directly, with no provider and no +ContentResolver involved: + +``` +OneShotImport + 1. does databases/tasks.db exist? no → nothing to do, mark done + 2. open SQLiteDatabase.OPEN_READONLY + 3. read tasklists → task_lists (account_type LOCAL → account_id NULL) + 4. read tasks → tasks (skip _deleted = 1; mint uid where NULL) + 5. read properties → task_alarms (mimetype = …/alarm only) + 6. verify counts, inside one Room transaction + 7. record completion in DataStore + 8. rename tasks.db → tasks.db.imported (kept one release, then deleted) +``` + +Rules that make this safe: + +- **Read-only, single transaction, verified counts.** Either the whole import + lands or none of it does. +- **The source file is renamed, never deleted**, for one release. If the import + is wrong we can still recover from a user's device. +- **Idempotent.** Guarded by a DataStore flag *and* by the rename, so a crash + mid-import cannot double-import. +- **Runs before first UI read**, gated the same way `StorageModeHolder.awaitReady()` + already gates the launch reminder re-sync. +- Tasks that were in an *external* account inside our bundled provider (only + possible if the user had pointed DAVx5 at our authority) are imported as + local lists, with their `uid` preserved. Rare, but preserving the UID is what + lets them be re-attached to an account later. + +`tasks.db.imported` is excluded from Auto Backup; the new Room database (with +its `-wal` and `-shm` sidecars) is included, which is the whole point of owning +it. + +### If the import goes wrong in production + +The rename is not just tidiness — it is the rollback. `tasks.db.imported` is a +complete, untouched dmfs database, so recovery does not need `:provider` to +still exist: + +1. `OneShotImport` can be re-run against `tasks.db.imported` as well as + `tasks.db`; the DataStore flag is clearable by a targeted fix release. +2. Re-import truncates the Room tables first and re-runs in one transaction, so + a second attempt is not a merge and cannot duplicate. +3. Only after a release with no import defects reported does a subsequent + version delete `tasks.db.imported`. + +This is the reason phase 5 (deleting `:provider`) ships *after* phase 4 rather +than with it — and the reason the deletion is its own release. + +--- + +## Effects on the sync plan + +`SYNC.md`'s phase list was written against the provider. Owning the store +deletes work from it outright: + +| `SYNC.md` item | Fate | +|---|---| +| "Assign UIDs at creation" (phase 0) | **gone** — `uid` is NOT NULL from v1 | +| Auto Backup / `cleanUpLists` data-loss guard (phase 0) | **gone** — no `cleanUpLists` | +| `lib-recur` pin rationale (phase 0) | reduced to a normal version choice | +| Local → Synced migration (phase 3) | **gone** — `account_id` is a nullable FK | +| ETag / href / CTag squats into `SYNC1`–`SYNC8` | **gone** — real columns | +| Per-collection sync token (phase 3) | **gone** — real column | +| `_DIRTY` set-on-delete workaround (phase 3) | **gone** — tombstones are ours | +| `CALLER_IS_SYNCADAPTER` ignored by instances URI | **gone** — no URIs | +| `Moving` dual-UID collision (phase 4) | **gone** | +| Read-only collections (phase 4) | now *possible* — `is_read_only` exists | +| Recurring-completion model | **decided here** — RECURRENCE-ID override | +| Byte-stable round-trip | improved — `unknown_properties` preserves the rest | + +Everything platform-level and protocol-level in `SYNC.md` is untouched: the +`targetSdk 34` sync-framework gate, the stub sync-adapter pattern, credential +storage, Play compliance, discovery, conditional `PUT`, conflict policy, and +every per-server quirk in the server-reality table. + +--- + +## Plan + +### Phase 0 — Untangle (0.5 wk) + +- `domain/Models.kt` stops importing `TasksContract`; the status, priority and + local-account constants move into `domain`. This is the last contract + reference above the data layer. +- `Task.id` → `Task.occurrenceStart`; `updateInstance(taskId, occurrenceStart, + form)`. `AndroidTasksDataSource` gains the lookup query, so the *existing* + provider path exercises the new signature before Room ever does. +- Add `StorageMode.OWN` as a **third** value alongside `LOCAL` and `EXTERNAL`. +- Add Room + `room.schemaLocation` to the version catalog (KSP is already + applied to `:app` for Hilt). + +Deliberately **not** here — both were in an earlier draft and both were wrong: + +- *Renaming `LOCAL` → `OWN`.* The provider is still the store until phase 4; + renaming now makes `OWN` mean dmfs for four phases and Room afterwards. + Phase 5. +- *Dropping our authority from `ProviderChangeReceiver`'s manifest filter.* That + receiver is what re-syncs reminders while the app is backgrounded + (`ProviderChangeReceiver.kt:47`). Removing the filter while the provider is + still live would silently stop background reminder updates. Phase 5. + +**Done when:** the app builds and behaves identically, provider still present, +still default, and the seam change is proven on the provider path. + +### Phase 1 — Schema and DAOs (1 wk) + +- The four entities above, plus DAOs, plus `schemas/` exported for migration + testing (`room.schemaLocation`, committed). +- `RoomTasksDataSource` implementing all 14 `TasksDataSource` methods except the + recurrence-dependent ones, which throw until phase 2. +- `DataModule` binds by `StorageMode`. + +**Done when:** a JVM test creates lists and non-recurring tasks through +`TasksDataSource` against an in-memory Room database and reads them back. + +### Phase 2 — Recurrence (1.5–2 wk) + +The hard phase. Budget accordingly. + +- `RecurrenceExpander` over `lib-recur`: `RRULE`, `RDATE`, `EXDATE`, overrides, + all-day handling, bounded window, `distanceFromCurrent`. +- `RECURRENCE-ID` override creation on single-occurrence edit and completion. +- A test suite that is the deliverable, not an afterthought: daily/weekly/ + monthly-by-day/yearly, `COUNT` and `UNTIL`, DST boundaries, all-day series, + a series with an override, a series with an exception, and an unbounded rule + hitting the window ceiling. + +**Done when:** the expansion suite is green and single-occurrence editing forks +correctly. + +⚠️ **Parity against the provider is only partly available, and the earlier draft +of this plan overclaimed it.** The provider materialises exactly one upcoming +occurrence, so there is no multi-occurrence behaviour to compare against. The +split: + +| Behaviour | Reference | +|---|---| +| Multi-occurrence expansion | RFC 5545 §3.8.5 and `lib-recur` directly — **no provider parity exists** | +| The single next occurrence | provider parity, while it is still in-tree | +| Editing one occurrence (forking) | provider parity — *except* model (a) vs (d), enumerated as explicit difference tests | +| All-day and DST handling | provider parity | + +That partial availability is still the reason deletion is phase 5 rather than +phase 0. It is just not the blanket safety net it was described as. + +### Phase 3 — Semantics parity (1 wk) + +- Completion coherence: `status` ↔ `percent_complete` ↔ `completed_at` ↔ closed, + replacing `AutoCompleting.java` and — importantly — the reopen asymmetry that + `TaskWriteMapper` currently works around in the app. +- Parent/child integrity: `parent_id` is `ON DELETE SET NULL`, so deleting a + parent promotes its subtasks rather than destroying them. +- Validation: `DUE` xor `DURATION`, `due >= dtstart`, all-day pinned to UTC + midnight, list must exist. +- Delete semantics: hard delete when `account_id IS NULL`, tombstone when set; + `master_id` cascades so a deleted series takes its overrides with it. +- `ICalendarWriter.uidFor`'s synthesis branch becomes dead on the Room path + (`uid` is NOT NULL). It stays for External, where UIDs really can be absent — + the KDoc gets updated to say which path each branch now serves. + +**Done when:** `TaskWriteMapper`'s provider-quirk workarounds are demonstrably +unnecessary on the Room path (they stay for External). + +### Phase 4 — Import and cutover (1 wk) + +- `OneShotImport` per the rules above, with tests over a fixture `tasks.db` + captured from a real v0.3.x install. +- `OWN` becomes the default for new installs and for upgraders after import. +- Backup rules updated: include the Room database **and its `-wal`/`-shm` + sidecars**, exclude `tasks.db.imported` and the Keystore blob. WAL checkpoint + on `ON_STOP`. + +**Done when:** an upgrade from a v0.3.2 APK with seeded data lands every task, +list and reminder in Room, verified by count and by content. + +### Phase 5 — Delete `:provider` (0.5 wk) + +- Remove the module, its `settings.gradle.kts` include, its `:app` dependency, + the three dmfs deps it pulled in, `provider/PROVENANCE.md`. +- Add `lib-recur` (and `rfc5545-datetime`) directly to `:app`. +- `StorageMode`: `LOCAL` is deleted, `OWN` is what remains beside `EXTERNAL`. + `ProviderResolver` loses `own` / `isOwn`. +- `ProviderChangeReceiver`'s manifest filter drops our own authority — safe + *now*, because nothing of ours broadcasts `ACTION_PROVIDER_CHANGED` any more. + In `OWN` mode the in-app `InvalidationTracker` observer covers foreground + changes, and until sync exists nothing outside the app can change our data. + **When `SYNC.md` phase 3 lands, the sync worker must call + `ReminderScheduler.sync()` itself** — that is the replacement for the + broadcast, and it belongs in the sync work, not here. +- Attribution screen: dmfs code is gone, but `lib-recur` stays and is + Apache-2.0. `PROVENANCE.md` is replaced by a short note in + `STORAGE-DECISION.md` recording that the fork existed and why it ended. +- **Release note, user-facing:** dropping the `` also drops the + `de.jeanlucmakiola.agendula.tasks` authority and both custom permissions. + Anyone who pointed DAVx5 or another app at that authority loses it silently — + it has to be called out in the release, with External mode as the answer. + +**Done when:** `./gradlew build` is green with `:provider` absent, and the APK +declares no ContentProvider and no custom permissions. + +### Phase 6 — Harden (1 wk) + +- Room migration test infrastructure (`MigrationTestHelper`) wired up, so v1 → + v2 is cheap when sync adds columns. +- Restore-path test: Auto Backup restore into a fresh install, **including the + WAL case** — write, background, restore, verify the last write survived. +- Performance check at 5,000 tasks with 20 recurring series. + +### Total + +| Phase | | | +|---|---|---:| +| 0 | Untangle | 0.5 | +| 1 | Schema and DAOs | 1 | +| 2 | Recurrence | 1.5–2 | +| 3 | Semantics parity | 1 | +| 4 | Import and cutover | 1 | +| 5 | Delete `:provider` | 0.5 | +| 6 | Harden | 1 | +| | | **6.5–7 wk** | + +Against which `SYNC.md`'s own estimate drops by 2.5–4 weeks, so the net cost of +owning the store is roughly **+2.5 to +4.5 weeks** — before counting the bugs +that stop being unfixable. + +> This does not contradict `STORAGE-DECISION.md`'s 4.5–6 week figure; it +> supersedes it. That estimate costed only the new store (schema, expansion, +> semantics, import, tests) on the assumption `:provider` would be *kept* +> alongside it. This plan deletes the provider, which adds phase 0's untangling +> and phase 5's removal — work the earlier figure never had to include. + +--- + +## Testing posture + +| Layer | How | +|---|---| +| Entities, DAOs, migrations | Room in-memory + `MigrationTestHelper`, JVM | +| `RecurrenceExpander` | pure JVM, no Android — the largest suite | +| Semantics (completion, hierarchy, validation) | JVM through `TasksDataSource` | +| `OneShotImport` | fixture `tasks.db` committed as a test resource | +| External mode | unchanged; existing `TaskMapper` / `TaskWriteMapper` tests stay | + +The 93 existing app tests must stay green throughout. The 56 provider tests +leave with the module in phase 5 — replaced, not abandoned: phases 2 and 3 owe +equivalent coverage of the behaviour those tests protected. Note the limit +recorded in phase 2: parity covers the single next occurrence, forking and +all-day/DST handling. Multi-occurrence expansion has no provider behaviour to +compare against and is tested against RFC 5545 and `lib-recur` directly. + +--- + +## Risks + +| Risk | Mitigation | +|---|---| +| **Recurrence is subtler than estimated** | The likeliest overrun, and the least mitigated — provider parity does not cover multi-occurrence expansion, so the reference is the RFC. Phase 2 is isolated and pure-JVM, so it can overrun without blocking phases 3–4. | +| **Import loses a user's data** | Read-only source, single transaction, count verification, source renamed not deleted, re-runnable from `tasks.db.imported`, fixture-based tests. | +| **Regression in a behaviour nobody documented** | Partial: phase 2 and 3 parity tests run against the provider while it is still present, for the behaviours where parity exists at all. That is why deletion is phase 5. | +| **A restore silently loses recent writes** | WAL checkpoint on `ON_STOP`, sidecars included in the backup rules, and a phase 6 test that exercises exactly this. | +| **Losing third-party interop** | External mode covers users who need it. Called out in the phase 5 release note. A read-only facade stays possible later; nothing here forecloses it. | +| **Room + KSP build cost** | KSP is already in the build for Hilt; Room adds one processor. | + +--- + +## Deliberately not doing + +- **An exported ContentProvider facade over Room.** Possible later (~1–1.5 wk), + not now. Shipping one would recreate the public-API surface whose validation + and URI plumbing is most of what we are deleting. +- **A domain-native schema.** The table shapes above stay recognisably close to + `TaskContract` where `TaskContract` was right, because it is a proven design + for `VTODO` and because it keeps a future facade cheap. +- **Flow-native DAOs.** Later refinement; changes the interface for no + user-visible gain today. +- **FTS / search.** The provider carried 798 lines of it. The app has never + called it. If search is wanted it is a feature request, designed on its own + terms. +- **Categories and attendees as first-class tables.** They round-trip through + `unknown_properties` until a feature actually needs them. diff --git a/docs/PLAN.md b/docs/PLAN.md index 4852655..90c7a2c 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -1,5 +1,24 @@ # Agendula — implementation plan +> ⚠️ **Historical document.** This is the original design plan, kept for the +> reasoning behind decisions that are still in force — the layering, the data +> model, the reminder engine, what transfers from Calendula. It is **not** a +> description of the app as it stands. +> +> Two things here have since been overturned, both by +> [`STORAGE-AND-SYNC.md`](STORAGE-AND-SYNC.md), which supersedes this document +> wherever they disagree: +> +> 1. **"No own storage."** Agendula now ships its own bundled task provider, and +> depending on an external provider app is a user choice rather than a +> requirement. +> 2. **"Posture B = bundle OpenTasks under `org.dmfs.tasks`."** That is a dead +> end, not a later step — two apps cannot declare the same authority or +> permission name. Posture B shipped under *our own* authority instead. +> +> For the current picture see [`ARCHITECTURE.md`](ARCHITECTURE.md); for status, +> [`ROADMAP.md`](ROADMAP.md). + > A modern Material 3 Expressive **task** app for Android. Reads, writes, and > reminds — on top of an existing tasks provider (synced by DAVx5 / SmoothSync / > DecSync over CalDAV), with no own sync stack. diff --git a/docs/README.md b/docs/README.md index eb146c7..b672819 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,8 +1,10 @@ # Agendula — documentation -Agendula is a Material 3 Expressive **task** app for Android: a pure front-end over -the OpenTasks `TaskContract` provider (synced by DAVx5 / SmoothSync / DecSync -over CalDAV), with no own database or sync stack. Sibling to +Agendula is a Material 3 Expressive **task** app for Android. It **carries its +own task store** — the dmfs task provider vendored under our own authority — so +it is complete and local-first with nothing else installed; an external provider +(OpenTasks / tasks.org, synced by DAVx5 / SmoothSync / DecSync) is a user choice +rather than a requirement, and our own CalDAV sync is the 1.x arc. Sibling to [Calendula](https://codeberg.org/jlmakiola/calendula). See the top-level [`../README.md`](../README.md) for the project pitch. @@ -12,15 +14,24 @@ top-level [`../README.md`](../README.md) for the project pitch. |---|---| | [`ARCHITECTURE.md`](ARCHITECTURE.md) | How Agendula is built **today** — layers, the data seam, provider resolution, the reminder engine, DI, build/tooling, manifest. Start here to work on the code. | | [`ROADMAP.md`](ROADMAP.md) | **Status** and what's next — milestones (M0–M6 + Posture B), what's done, open decisions, how to build/verify. | +| [`STORAGE-AND-SYNC.md`](STORAGE-AND-SYNC.md) | **Where task data lives** — the decision to ship our own provider, the storage modes, permissions, distribution, and the dead ends. Supersedes `PLAN.md` on storage. | +| [`SYNC.md`](SYNC.md) | **How data reaches a server** — the CalDAV sync adapter: the VTODO ↔ `TaskContract` mapper, Nextcloud sign-in, the engine, libraries and their licenses. Step 5 of `STORAGE-AND-SYNC.md`. | +| [`STORAGE-DECISION.md`](STORAGE-DECISION.md) | **Keep the vendored provider, or build our own?** The measured cost of both. **Decided: build our own.** | +| [`OWN-STORE.md`](OWN-STORE.md) | **Agendula's own Room store** — the schema, recurrence design, migration off the vendored provider, and the six-phase plan that deletes `:provider`. Supersedes the "keep the provider" position in `STORAGE-AND-SYNC.md`. | | [`PLAN.md`](PLAN.md) | The original implementation plan and **design rationale** — the A-now-B-later thesis, what transfers from Calendula, the locked decisions. The "why". | | [`RELEASING.md`](RELEASING.md) | How to cut a release — the git-tag-as-source-of-truth flow, CI jobs, F-Droid repo, required secrets. | +| [`../provider/PROVENANCE.md`](../provider/PROVENANCE.md) | What the vendored `:provider` module is, where it came from, and **every** deviation from upstream dmfs. | Also: [`../CHANGELOG.md`](../CHANGELOG.md) (Keep a Changelog format; tag sections feed the release notes). ## How the docs relate -- **PLAN** is the design decisions (mostly stable; the "why"). +- **PLAN** is the original design decisions (the "why"), left as the historical + record. On storage it is **superseded by STORAGE-AND-SYNC**. +- **STORAGE-AND-SYNC** and **SYNC** are the standing decision documents: the + first settles where data lives, the second how it syncs. Both record rejected + alternatives on purpose, so decisions don't get relitigated. - **ARCHITECTURE** is the current shape of the code (kept in sync with the source as it grows). - **ROADMAP** is the moving status layer (update as milestones land). diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0c7e47d..945d92b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -10,16 +10,20 @@ Status legend: ✅ done · 🚧 in progress · ⬜ not started ## Current state (one line) -The full non-visual stack ("backoffice") over the OpenTasks `TaskContract` -provider is **done and unit-tested**, and the Material 3 Expressive UI is built -through **M5**: lists → task list (swipe gestures, inline add, smart-list section -headers) → detail / edit with full CRUD, date-time pickers, priority, -percent-complete, conflict-safe saves, per-task reminders, and subtask -create + reparent — plus a one-time reminder onboarding step and a Settings -screen (theme, dynamic colour, due-reminder master toggle + default offset + -exact-alarm status, default list, and the add-a-subtask-row opt-out). Remaining -work is M6 (Glance widget, translations, F-Droid release; the Settings screen -landed early with M5 and still needs a language entry). +Agendula now **carries its own task store, and it is one we wrote**: a Room +database designed against `VTODO`, with recurrence expanded at read time. The +vendored dmfs provider and the `:provider` module that held it are deleted, a +v0.3.x install's tasks are imported on first launch, and an external provider +(OpenTasks / tasks.org) is a user choice rather than a requirement. Export to +iCalendar has landed, and the Material 3 Expressive UI is built through **M5**: +lists → task list (swipe gestures, inline add, smart-list section headers) → +detail / edit with full CRUD, date-time pickers, priority, percent-complete, +conflict-safe saves, per-task reminders, and subtask create + reparent — plus a +one-time reminder onboarding step and a Settings screen. The frontend surfaces +for the new store have landed too: a Settings **Storage** section with the +store picker and an **export screen**. Remaining work is verifying all of it on +a device, then M6 (Glance widget, translations, F-Droid release) and the sync +adapter. --- @@ -128,11 +132,125 @@ The engine exists (M1: `ReminderScheduler` + boot / provider-change re-sync, - ⬜ Translations — only `res/values/` (English); no `values-XX`. - ⬜ Finalize F-Droid metadata, confirm CI release flow. -### ⬜ Posture B (separate track, later) -Add a `:provider` module bundling the Apache-2.0 `opentasks-provider`; -`ProviderResolver` defaults to our own `org.dmfs.tasks`; add sync-adapter -permissions; ship self-contained. UI / repository / domain untouched — see -[`ARCHITECTURE.md`](ARCHITECTURE.md) §7. +### ✅ Posture B — our own task store +Agendula stopped depending on a provider app being installed. Direction and +reasoning in [`STORAGE-AND-SYNC.md`](STORAGE-AND-SYNC.md); note it **redefined** +what Posture B means (our own store, coexisting with everything — *not* squatting +`org.dmfs.tasks`, which is a dead end). +- ✅ `fix/provider-interaction-review` merged (step 1). +- ✅ Step 2, first pass — the Apache-2.0 dmfs provider 1.4.2 (DB 23) vendored + in-tree as `:provider`, under `de.jeanlucmakiola.agendula.tasks` and our own + permission namespace. Shipped in v0.3.x and **since superseded**: see "our own + store" below. +- ✅ Storage modes + the permission-gate bypass — `ProviderStatus.NEEDS_PERMISSION` + can no longer fire in our own mode, and an upgrading Posture A user stays on the + provider that holds their data (`ProviderResolver.autoMode`). +- ✅ Export to iCalendar (step 3) — a v1 feature now that own-mode data lives + only in our app's private storage. One `.ics` per list, to a folder or a zip, + via SAF. Backend only. +- ✅ **Frontend surfaces for the above** — Settings gained a **Storage** section + holding both: a full-screen store picker (Own / an installed external provider, + which is dimmed when none is present) that asks for the provider's runtime + permission *before* committing the switch, and an export screen with a per-list + tick and the two SAF destinations, a folder or a single zip. Two consequences + of the mode becoming switchable at runtime came with it: reminders are re-armed + against the new store on every switch (`AgendulaApp` listens on + `ProviderResolver.onModeChanged`; previously only a restart, a boot or an edit + resynced them), and the permission gate offers a way back to our own store — + otherwise a user whose provider app went away is held on a gate with Settings + behind it. +- ⬜ File the DAVx5 issue (step 4) — non-blocking, cheap, serves F-Droid users. + Note it now means "sync into an app that has no provider", so the ask has + changed shape. +- ⬜ Sync adapter (step 5) — the 1.x arc. **Designed in [`SYNC.md`](SYNC.md)**, + not started: mapper → auth → engine → hardening, ~8–9 weeks, minus the 2.5–4 + weeks owning the store deletes from it (`OWN-STORE.md`, "Effects on the sync + plan"). The account model is settled (`AccountManager`) and `ical4android` is + closed out (superseded by `synctools`, GPLv3, so we write the mapper + in-house); what's still open is dav4jvm's JitPack-only distribution, conflict + policy, and whether External mode survives the milestone. + +### ✅ Our own store — Room, and the provider deleted +The vendored provider was kept because it appeared to hand us the sync +bookkeeping for free; the phase-1 sync audit measured that bookkeeping and found +most of it broken, absent or unusable. Reasoning in +[`STORAGE-DECISION.md`](STORAGE-DECISION.md), architecture and six-phase plan in +[`OWN-STORE.md`](OWN-STORE.md). +- ✅ Phase 0 — occurrences addressed by `(taskId, occurrenceStart)`. `Task.id` + (the materialised instance row id) dropped for `occurrenceStart`, lazy-list + keys moved to `Task.occurrenceKey`, `updateInstance` re-signed, `domain/` + stopped importing `TasksContract`. Done while the provider was still the store, + so the External path exercised the new seam first. +- ✅ Phase 1 — the schema: `task_lists`, `tasks`, `task_alarms`, `accounts`, a + DAO per table, the v1 schema JSON committed for migration testing. Masters and + `RECURRENCE-ID` overrides share the `tasks` table, so the unique index is + `(list_id, uid, recurrence_id)`. +- ✅ Phase 2 — `RecurrenceExpander` over `lib-recur` 0.12.2: a series expanded in + memory at read time, bounded by a window (1 year back, 2 forward) and a hard + per-series ceiling. No materialised instances table, so none of its staleness + bugs. 38 tests against RFC 5545 directly, since the provider only ever + materialised one occurrence to compare against. +- ✅ Phase 3 — `RoomTasksDataSource` implements all 14 seam methods, picked per + call by `ModeRoutingTasksDataSource`. Editing one occurrence writes a + `RECURRENCE-ID` override sharing the master's UID (RFC 5545 model (a)), where + the provider forked a new task with a new UID (model (d)). Completion rules are + stated directly rather than worked around, so a task can no longer strand + itself "done at 75%". +- ✅ Phase 4 — `OneShotImport` moves a v0.3.x install's `databases/tasks.db` into + Room on first launch, archiving the source as `tasks.db.imported`; `OWN` is the + default; `StartupGate` holds the first store read until the mode has landed and + the import has run; backup rules take the database with its WAL sidecars and + the app checkpoints on `ON_STOP`. +- ✅ Phase 5 — `:provider` deleted: 84 Java files, 14,555 lines, its ``, + its two custom permissions and its three dmfs runtime dependencies. + `StorageMode.LOCAL` is gone (a stored `LOCAL` reads as `OWN`); `ProviderResolver` + narrows to discovering external providers; `ProviderChangeReceiver` filters on + the two external authorities only. `provider/PROVENANCE.md` is replaced by a + postscript in `STORAGE-DECISION.md`. **Breaking:** the + `de.jeanlucmakiola.agendula.tasks` authority and both custom permissions no + longer exist — anyone who pointed DAVx5 at that authority loses it, and the + release notes have to say so. +- ✅ Phase 6 — harden: `MigrationTestHelper` wired against the committed v1 + schema so v1 → v2 is cheap when sync adds columns, an Auto Backup restore test + covering the WAL case in both directions, and a performance check at 5,000 + tasks with 20 recurring series. +- ✅ Fallout: `ReminderScheduler.sync()` gated on `resolve() != null`, which is + what `OWN` returns, so no due reminder armed in the default mode. It now gates + on `ProviderResolver.canReadStore()`, with tests. +- ✅ Fallout, second pass — four defects a review of the branch turned up: + **completing one occurrence closed the whole series** (`setCompleted` wrote the + master, which is the row `TaskDao.tasks` filters on, so every occurrence left + every list); the **expansion ceiling was spent on the past**, so a sub-daily + series stopped expanding months before today and never reached Today or + Upcoming; an imported **`START`-referenced reminder fired off `DUE`**, because + the seam collapsed alarms to a bare minute count; and `registerObserver` bound + a live flow to whichever store was active at subscription, so a Settings + store switch would have left every screen listening to the store it had + stopped reading. `setCompletedInstance` now forks a `RECURRENCE-ID` override + the way `updateInstance` does — phase 2 always specified this, only the edit + half had it. +- ⬜ **Run the instrumented suite on a device.** Six classes — the Room seam, the + DAOs, the import, the migration harness, the restore path and the performance + check — all compile and none has ever executed. Everything load-bearing about + this migration is verified only by tests that have not run. +- ⬜ Verify on a device: a fresh install on the Room store, and an upgrade from a + v0.3.2 APK with seeded data landing every task, list and reminder. +- ⬜ Per-locale release notes for the dropped authority and permissions. + +### ✅ Managing lists in the app +Owning the store made this mandatory: there is no longer a provider app to +create a list in, so a fresh install had no lists, no way to make one, and +therefore no way to save a task. The seam gained `updateList` / `deleteList` +beside the existing `createLocalList`, implemented on both the Room and the +External path (which addresses the row as its own account's sync adapter, the +only caller the provider lets write `tasklists`). +- ✅ `ListEditorSheet` — the family's full-screen sheet with a name field, the + 12-colour palette and, when editing, a destructive row behind a confirm. +- ✅ Entry points: a "New list" row under the home Lists section, a real empty + state with a create button, and the home FAB switching to "New list" while + there are none. Editing is the pencil in a list's own top bar. +- ✅ Deleting a list deletes its tasks — `tasks.list_id` cascades — and is + offered only for device-only lists; an account's collection is its server's. --- @@ -144,12 +262,24 @@ These carry over from [`PLAN.md`](PLAN.md) §9; resolved ones are struck through 2. ~~**tasks.org provider authority**~~ verified on device: `org.tasks.opentasks` + `org.tasks.permission.*`. 3. **jtx Board** — support its richer contract later, or stay OpenTasks-only? - (Not in the candidate list today.) -4. **Posture B authority choice** — bundling `org.dmfs.tasks` makes Agendula a - *replacement* for OpenTasks (one authority owner per device). Intended, but a - conscious choice. -5. **Recurring tasks** — read as occurrences today (`isRecurring` flag exists); - recurrence-aware editing is out of scope for v1. + (Not in the candidate list today.) Note this is now downstream of + [`SYNC.md`](SYNC.md) open question 3: if External mode is retired once we sync + ourselves, the question disappears with it. +4. ~~**Posture B authority choice**~~ moot: Agendula publishes no provider and + holds no authority at all. The question was live while the store was a + vendored provider, and squatting `org.dmfs.tasks` was a dead end even then — + two apps cannot declare the same authority or permission name, so anyone with + OpenTasks installed could not have installed Agendula at all. +5. ~~**Recurring tasks** — recurrence-aware editing out of scope for v1~~ + resolved: our own store expands a series at read time and writes an edit to + one occurrence as a `RECURRENCE-ID` override sharing the master's UID; in + External mode the edit still goes through the instances URI. +6. ~~**Resolver ordering / mode-selection UX**~~ resolved: `autoMode()` picks the + default (see [`ARCHITECTURE.md`](ARCHITECTURE.md) §4.1) and Settings → Storage + → Task store is the override it always assumed. +7. ~~**Sync protocol coverage**, account model, conflict resolution — the next + design discussion.~~ Taken up in [`SYNC.md`](SYNC.md); the remaining opens + live on that document's list. --- @@ -157,7 +287,12 @@ These carry over from [`PLAN.md`](PLAN.md) §9; resolved ones are struck through - Build: `./gradlew :app:assembleDebug` - Unit tests: `./gradlew :app:testDebugUnitTest` -- Run on a device/emulator that has **OpenTasks** or **tasks.org** installed (and - ideally DAVx5 syncing a CalDAV task list) so the read/write paths have real - data. Debug builds use `DemoSeeder` for sample data when no provider data is - present. +- Instrumented tests: `./gradlew :app:connectedDebugAndroidTest` — the Room + schema, `RoomTasksDataSource` and `OneShotImport` (the last against + `app/src/androidTest/assets/tasks-v23.db`, regenerated by + `scripts/make_import_fixture.py`). +- Any device or emulator will do for the default path: the store is ours and + needs nothing installed. Debug builds seed an "Agendula Demo" list via + `DemoSeeder` unless it already exists. To exercise **External** mode, use a + device with **OpenTasks** + or **tasks.org** installed, and ideally DAVx5 syncing a CalDAV task list. diff --git a/docs/STORAGE-AND-SYNC.md b/docs/STORAGE-AND-SYNC.md index d6831a9..7224a45 100644 --- a/docs/STORAGE-AND-SYNC.md +++ b/docs/STORAGE-AND-SYNC.md @@ -1,12 +1,26 @@ # Agendula — storage and sync +> ⚠️ **Partly superseded, 2026-08-13.** The core decision below — *vendor the +> dmfs provider in-tree as `:provider`* — has been **reversed**. Agendula builds +> its own Room store and deletes the vendored provider; External mode (OpenTasks, +> tasks.org) is unaffected and everything this document says about it still +> stands. See [`STORAGE-DECISION.md`](STORAGE-DECISION.md) for why and +> [`OWN-STORE.md`](OWN-STORE.md) for what replaces it. The permissions, +> distribution, storage-mode and dead-end sections below remain accurate; treat +> the "our own provider" sections as the historical record of a decision that was +> made, shipped, and then costed properly. + > Decided direction, captured 2026-08-01. Supersedes the earlier "Posture B = > bundle OpenTasks" working notes, which are withdrawn (see > [Dead ends](#dead-ends--do-not-revisit)). This is the detailed companion to > `ARCHITECTURE.md` §7 and the `ProviderResolver` comments, **and it redefines -> what Posture B means** — those two need a follow-up edit. -> `ROADMAP.md` / `PLAN.md` remain known-stale and are due a deliberate pass; -> this document does not attempt it. +> what Posture B means.** +> +> **Status, 2026-08-02: steps 1–3 are done** — see +> [Sequencing](#sequencing). `ARCHITECTURE.md` and `ROADMAP.md` have had their +> follow-up pass and now match what shipped; `PLAN.md` is the original design +> document and is left as the historical record. What is built, and what is still +> only described here, is marked step by step below. ## The plan, in short @@ -24,13 +38,13 @@ **In what order** -| # | Step | Why now | -|---|---|---| -| 1 | Merge `fix/provider-interaction-review` | unmerged and rotting; touches the same permission flow as step 2 | -| 2 | Vendor `:provider` under our own authority | the identity, done once — and it ships a complete local-first app | -| 3 | Export / backup | our data now lives only in our app's private storage | -| 4 | File the DAVx5 issue | cheap, non-blocking, serves F-Droid users | -| 5 | Sync adapter | the 1.x arc; design discussion pending | +| # | Step | Why now | Status | +|---|---|---|---| +| 1 | Merge `fix/provider-interaction-review` | unmerged and rotting; touches the same permission flow as step 2 | ✅ done | +| 2 | Vendor `:provider` under our own authority | the identity, done once — and it ships a complete local-first app | ✅ done | +| 3 | Export / backup | our data now lives only in our app's private storage | ✅ done, UI included | +| 4 | File the DAVx5 issue | cheap, non-blocking, serves F-Droid users | ⬜ | +| 5 | Sync adapter | the 1.x arc; designed in [`SYNC.md`](SYNC.md), not yet built | ⬜ | Everything below is the reasoning behind those choices, the alternatives that were rejected, and the constraints they have to survive. @@ -64,8 +78,8 @@ Plus a standing rule: **anything that isn't task-domain goes to floret-kit.** ### The vocabulary, redefined -`ARCHITECTURE.md` §7 and `ProviderResolver`'s KDoc still describe Posture B as -"bundle OpenTasks and find `org.dmfs.tasks` first." Replace with: +`ARCHITECTURE.md` §7 and `ProviderResolver`'s KDoc used to describe Posture B as +"bundle OpenTasks and find `org.dmfs.tasks` first." Both now read as below: - **Posture A** — front-end over an *external* provider (OpenTasks, tasks.org). Still fully supported; it stops being the default and becomes a **user @@ -170,22 +184,45 @@ feature — see [Storage modes](#storage-modes--the-users-choice). | **Synced** | our bundled provider | our sync adapter | network + an account the user configures | | **External** | OpenTasks / tasks.org | whatever that provider's engine does (DAVx5 …) | that provider's `READ`/`WRITE_TASKS`, granted at runtime | -Local and Synced are the same store — Synced is Local with an account attached, -so switching on sync is not a migration. +Local and Synced are the same store — but ⚠️ **switching on sync *is* a +migration, contrary to what this document said until 2026-08-13.** The provider +enforces `ACCOUNT_NAME` and `ACCOUNT_TYPE` as **write-once** on a task list +(`processors/lists/Validating.java:68-76`, which throws), so a list created under +`org.dmfs.account.LOCAL` can never be re-pointed at a real account. Enabling sync +means creating new lists under the account and moving tasks into them. See +[`SYNC.md`](SYNC.md) — it is a costed deliverable there, not a free consequence. -**Resolver ordering needs deciding.** Today `ProviderResolver.CANDIDATES` is a -fixed priority list and the first hit wins. Once we bundle our own provider, -"first hit" is the wrong rule: someone who used Agendula locally and *later* -installs DAVx5 + OpenTasks would see an external candidate outrank the provider -that actually holds their data. Options: rank ours first whenever it's -non-empty, or make the mode an explicit Settings choice (it's user-visible -either way, so probably both — auto-pick a sane default, let Settings override). +**Resolver ordering — decided, and it went both ways as expected.** +`ProviderResolver` now takes an explicit `StorageMode` from Settings when there +is one, and otherwise calls `autoMode()`. The auto rule turned out to be sharper +than "rank ours first whenever it's non-empty", and needs no database probe: -**Export/backup is a v1 feature.** Not, as previously framed, a migration safety -net for "uninstall OpenTasks" — that scenario no longer exists. It's data -portability for Local-mode users, whose tasks otherwise exist in exactly one -place with no second copy. On Play, where most users won't have a sync engine, -that's the majority. +> **External if we already hold an external provider's runtime permission, +> otherwise Local.** + +That permission is dangerous-level, so it can only be there because an earlier +version asked and the user agreed — which is exactly what "existing Posture A +user" means. A fresh install holds nothing and gets local-first. ✅ The Settings +override the rule assumes is built: Storage → Task store, which asks for the +external provider's permission before committing the switch rather than after. + +**Note on the mode vocabulary.** The code has two modes, not three: +`StorageMode.LOCAL` and `StorageMode.EXTERNAL`. As this document says two +paragraphs up, Synced *is* Local with an account attached — so it is derived +state, and giving it its own constant would imply switching sync on is a +migration when the whole point is that it isn't. + +**Export/backup is a v1 feature.** ✅ Built, screen included. Not, as +previously framed, a migration safety net for "uninstall OpenTasks" — that +scenario no longer exists. It's data portability for Local-mode users, whose +tasks otherwise exist in exactly one place with no second copy. On Play, where +most users won't have a sync engine, that's the majority. + +One `.ics` per list — a list is a CalDAV collection, and that's the unit other +clients understand — written through SAF to either a folder or a single zip. +Local tasks have no `_uid` (only a sync adapter may assign one), so the writer +synthesises a stable UID per task; without it a re-imported backup would +duplicate every task instead of matching it. --- @@ -214,10 +251,17 @@ compliance and a small enum-shaped addition with near-zero ongoing maintenance for them. Say that explicitly. "Here's a change that can't break anything" lands very differently from "please support my app." -**Open — the next discussion.** Protocol coverage ("support as much as -possible"), the account model, conflict resolution, and where the DAV/iCalendar -work lives. One constraint to settle early: we're MIT; `dav4jvm` is Apache-2.0 -and fine, but **verify `ical4android`'s license** before assuming it's usable. +**The design is now written out in [`SYNC.md`](SYNC.md)** — protocol coverage, +the account model, conflict resolution, the VTODO ↔ `TaskContract` mapper, and +where the DAV/iCalendar work lives. Two corrections to what this section +originally said, both verified 2026-08-13: + +- `dav4jvm` is **MPL-2.0**, not Apache-2.0. Still fine against our MIT (file-level + copyleft), but it is ⚠️ **JitPack-only**, which collides with our + `FAIL_ON_PROJECT_REPOS` + `google()`/`mavenCentral()` policy and with the + JitPack dead end below. `SYNC.md` open question 1. +- `ical4android` is **superseded by `synctools`, which is GPLv3** — so it is + unusable, and the open question below is closed. We write the mapper in-house. --- @@ -241,10 +285,11 @@ mechanisms, and conflating them is how apps end up over-permissioned: | `INTERNET` | **don't declare it until sync ships** | | `GET_ACCOUNTS` | never — stripped from the vendored provider; our own account type doesn't need it to see its own accounts | -**Work item:** the permission gate in `RootScreen` / `PermissionViewModel` / -`ProviderResolver.hasPermission` currently assumes an external provider always -needs a grant. It needs a bypass for our own provider. Modest, but it's the -exact flow `fix/provider-interaction-review` just touched — merge that first. +~~**Work item:** the permission gate … needs a bypass for our own provider.~~ +✅ Done. `ProviderResolver.hasPermission` short-circuits to `true` when +`TaskProvider.isOwn`, so `ProviderStatus.NEEDS_PERMISSION` cannot fire in +Local/Synced mode, and `PermissionViewModel` never offers our own permissions to +the request launcher. Covered by `ProviderResolverTest`. --- @@ -259,7 +304,7 @@ task-domain, it goes to the kit. | ContentProvider seam — `ColumnReader`, failures, observer→Flow | `core-provider` | **already on the kit's deferred list**, blocked on migrating Calendula to the name-based reader. Bundling our own provider is the forcing function that makes this worth doing. | | Runtime-permission staging — request/state machine, rationale plumbing, "ask at point of use" | new, e.g. `core-permissions` | pure mechanics, and Calendula has the identical problem | | DAV client + iCalendar parse/serialize | new, e.g. `core-dav` | **the big one.** Calendula is a calendar app; it needs the same primitives. Worth designing for two consumers from the start rather than extracting later | -| Export/backup plumbing — SAF, file writing, share-out | kit | the *serialization* of tasks is domain; the plumbing isn't | +| Export/backup plumbing — SAF, file writing, share-out | kit | the *serialization* of tasks is domain; the plumbing isn't. **Built app-local for now** (`data/export/ExportWriter`), on the kit's own principle of not extracting before a second consumer exists — the seam is in place, so moving it is a file move. `ICalendarWriter` stays app-local permanently: it's domain. | | Sync-adapter/account scaffolding | kit, probably | the `AbstractThreadedSyncAdapter` + authenticator boilerplate is identical everywhere; the delta logic is domain | **Stays app-local:** the vendored `:provider` module (task-specific, and @@ -314,6 +359,13 @@ Not on either yet; both are targets, so build *for* them rather than retrofittin throwaway spike* (a library string resource can be overridden from the app module, so the authority rename works), but not for anything we ship. + ⚠️ **This one comes back.** It is a dead end *for the provider*, where + vendoring was mandatory anyway. `dav4jvm` is JitPack-only, so the sync adapter + has to answer the same question on its own terms — and F-Droid turns out not to + be the obstacle (its inclusion policy trusts jitpack.io for freely-licensed + artifacts); our own trust-surface policy is. See [`SYNC.md`](SYNC.md) open + question 1. + --- ## Sequencing @@ -336,15 +388,39 @@ the roadmap should say so rather than inheriting the old estimate. ## Open questions -1. **Sync protocol coverage**, account model, conflict resolution — the next - discussion. -2. **Resolver ordering / mode selection UX** once our provider coexists with - external ones (see [Storage modes](#storage-modes--the-users-choice)). -3. **Does the vendored provider work with no account at all?** Local-only mode - depends on it entirely. First thing the vendoring work should prove. -4. **`ical4android` licensing** vs our MIT. +1. **Sync protocol coverage**, account model, conflict resolution — ✅ taken up in + [`SYNC.md`](SYNC.md). The account model is answered there (`AccountManager`, + which `PROVENANCE.md` change 1 already assumes); what stays open moves to that + document's own list — dav4jvm's distribution, conflict policy, External mode's + future, and recurring-task completion. +2. **Resolver ordering** — ✅ decided, see + [Storage modes](#storage-modes--the-users-choice). The **mode-selection UX** + is still open: `autoMode()` picks a default, but the Settings override it + assumes does not exist yet. +3. **Does the vendored provider work with no account at all?** ✅ Answered, with + a caveat about *how* it was answered. + + By construction: `cleanUpLists` exempts local lists explicitly (upstream's own + rule), and our rework restricts pruning to account types this package + authenticates — currently none — so nothing can be pruned at all. + `ProviderAccountCleanupTest` creates a local list and a task in it with zero + accounts present and reads both back. + + ⚠️ **But that test is Robolectric, and it skips on ARM64**, where Robolectric + has no SQLite backend in either mode. It runs on x86_64 CI. It is not a + substitute for a device, and this remains on the device-verification list. +4. ~~**`ical4android` licensing** vs our MIT.~~ ✅ Closed: `ical4android` is + superseded by `synctools`, which is **GPLv3**, so it is out — as is + `cert4android`. The export path never depended on it anyway (`ICalendarWriter` + is our own ~200 lines). The sync adapter's iCalendar parsing goes through an + in-house mapper over `ical4j`/`biweekly`; see [`SYNC.md`](SYNC.md). 5. **jtx Board** as an additional External-mode candidate — richer contract, later. (`PLAN.md` decision #3, still open.) +6. **The vendored provider's timezone-change behaviour** — upstream's receiver + has a comment describing `break`s that were never written. We preserved the + observed behaviour and wrote it out explicitly; which of code or comment is the + bug wants a device to settle. Change 3 in + [`provider/PROVENANCE.md`](../provider/PROVENANCE.md). --- diff --git a/docs/STORAGE-DECISION.md b/docs/STORAGE-DECISION.md new file mode 100644 index 0000000..8bddcc0 --- /dev/null +++ b/docs/STORAGE-DECISION.md @@ -0,0 +1,284 @@ +# Storage: keep the vendored provider, or build our own? + +**Status:** **decided — build our own.** See [`OWN-STORE.md`](OWN-STORE.md) for +the architecture and plan; this document is the reasoning that got there. + +The decision went further than the recommendation below: `:provider` is not kept +alongside a Room store, it is **deleted**. External mode (OpenTasks, tasks.org) +stays. The staged sequencing survives in a different form — the provider remains +in-tree until `OWN-STORE.md` phase 5 so recurrence parity can be tested against +it, then goes. + +This reopens a question `SYNC.md` marked settled. It is reopened on purpose: the +argument that settled it was *"the provider hands us the sync bookkeeping for +free"*, and the phase-1 audit found most of that bookkeeping broken, absent, or +unusable for our purposes. A conclusion is only as good as its premise. + +--- + +## The three options + +| | What it means | Store | Exported provider | +|---|---|---|---| +| **A** | Keep `:provider` as-is | dmfs `TaskProvider` | yes, ours today | +| **B** | Room, same `TaskContract` shape | our Room DB | dropped, or a later facade | +| **C** | Room, clean domain schema, `TaskContract` only as an export format | our Room DB | no | + +External mode (talking to OpenTasks / tasks.org) is orthogonal and survives all +three. It is the reason nothing below gets deleted. + +--- + +## What the swap actually touches — measured, not estimated + +### The seam is already there, and it is clean + +`TasksDataSource` (`data/tasks/TasksDataSource.kt`, 58 lines) is a **14-method, +domain-shaped interface**. It takes and returns `Task`, `TaskList`, `TaskForm` — +no `Cursor`, no `Uri`, no `ContentValues`. + +Above it, **17 files** import from `data.tasks`. What they import: + +``` +8 × TasksRepository 4 × TasksDataSource 3 × ProviderResolver +4 × recoveringFromProviderFailure 2 × ProviderStatus +1 each: StorageMode, StorageModeHolder, ProviderEnvironment, TaskQuery, … +``` + +**Exactly one file outside the data package touches `TasksContract` at all** — +`domain/Models.kt`, and only for four status integers, one priority constant and +one account-type string. Ten lines. Nothing else above the data layer knows a +ContentProvider exists. + +> The whole UI, all five milestones of it, is untouched by a storage swap. +> That is not luck — `AndroidTasksDataSource`'s own KDoc says the seam exists so +> that *"swapping the provider never reaches above this file."* It holds. + +### Nothing gets deleted + +| File | Lines | Under a Room store | +|---|---|---| +| `AndroidTasksDataSource.kt` | 220 | **kept** — External mode still needs it | +| `TasksContract.kt` | 178 | **kept** — External mode speaks it | +| `TasksRepositoryImpl.kt` | 151 | unchanged | +| `ProviderResolver.kt` | 143 | unchanged | +| `TaskWriteMapper.kt` | 118 | **kept** for External | +| `TaskMapper.kt` | 102 | **kept** for External | +| `TasksDataSource.kt` | 58 | unchanged — it is the interface | +| `TasksRepository.kt` | 53 | unchanged | +| `ProviderEnvironment.kt` | 51 | unchanged | +| `StorageModeHolder.kt` | 47 | unchanged | +| `ColumnReader.kt` | 40 | **kept** for External | +| `ProviderFlow.kt` | 31 | unchanged | +| `StorageMode.kt` | 30 | one new constant | +| `TaskProjections.kt` | 24 | **kept** for External | +| `Failures.kt` | 16 | unchanged | +| | **1,262** | **0 removed** | + +The work is **additive**: a second `TasksDataSource` implementation, a third +`StorageMode`, and one `@Binds` becoming a dispatcher. `DataModule.kt` has a +single binding to change. + +This reframes the question. It is not *rewrite vs. keep*. It is **write a second +backend behind an interface that exists for exactly this purpose, and run both +until one wins.** + +--- + +## What the new backend has to do + +Room entities and DAOs for the ~50 columns the app actually uses across four +tables are mechanical. The real work is the behaviour the provider's processors +perform. Measured against `:provider`'s Java: + +| Behaviour | Provider | Notes | +|---|---:|---| +| Instance expansion | ~1,070 | `Instantiating` + `instancedata` + iterables. **The hard one.** | +| Recurring-instance edit | 337 | `Detaching` — this *is* the recurrence-model decision | +| Completion coherence | 210 | `AutoCompleting`: status ↔ percent ↔ completed ↔ is_closed | +| Validation | 601 | three processors, mostly defending a *public* API | +| Parent / child | 269 | we use `parent_id` only | +| Alarm property rows | 133 | one Room entity | +| | **~2,620** | | + +And what we would **not** write, of the 14,555 vendored lines: + +| Not needed | Lines | Why | +|---|---:|---| +| `TaskDatabaseHelper` | 895 | 23 migrations from a 2013 schema. We start at v1. | +| `FTSDatabaseHelper` + ngrams | 798 | **the app never searches the provider** — verified, zero call sites | +| `model/adapters` | 1,581 | a type-safe layer over `ContentValues`. Room entities delete the problem. | +| `model` | 1,811 | cursor ↔ entity adaptation. Room's job. | +| `TaskProvider` + `SQLiteContentProvider` | 1,772 | URI matching, permissions, batch ops — for a public API | +| `CategoryHandler` + `RelationHandler` | 553 | unused | +| `utils` (most) | ~800 | dmfs jems idiom → Kotlin stdlib | +| **≈ 8,200 lines we would simply not have** | | | + +Two things make instance expansion less frightening than its line count: + +1. **We need client-side recurrence expansion regardless.** Server-side + `CALDAV:expand` on `VTODO` is broken on every server we target (`SYNC.md`), + so `lib-recur` is in the build either way. +2. **We would use the same eight `lib-recur` classes the provider does** — + `RecurrenceRule`, `RecurrenceSet`, `RecurrenceSetIterator`, `RecurrenceList`, + `RecurrenceRuleAdapter`, `DateTime`, `Duration`, + `InvalidRecurrenceRuleException`. The algorithm is in the library, not in the + provider. +3. And the provider's expansion **materialises only one upcoming occurrence + anyway** — it is not the complete implementation its size suggests. + +--- + +## The cost, both directions + +### Building it + +| | | +|---|---:| +| Schema, entities, DAOs | 1 wk | +| Instance expansion on `lib-recur`, with a real test suite | 1.5–2 wk | +| Completion / parent / validation semantics | 1 wk | +| Recurring-edit model — *shared cost, phase 1 either way* | (0.5–1 wk) | +| Migrating existing users' local data out of the provider | 0.5 wk | +| Tests to parity with the current 93 + 56 | 1 wk | +| **Net additional** | **4.5–6 wk** | + +> ⚠️ Superseded by [`OWN-STORE.md`](OWN-STORE.md)'s **6.5–7 wk**. The figure +> above costed the new store only, on the assumption `:provider` would be kept +> beside it. The decision taken was to delete the provider, which adds the +> untangling (phase 0) and the removal (phase 5) that this estimate never had to +> include. The costing logic stands; the total does not. + +### What it removes from the sync plan + +Roughly sixteen of the phase-1 audit's storage findings are **provider-imposed** +— they exist only because we run dmfs's implementation, and vanish when we own +the store: + +- `_DIRTY` not set on delete, and defaulting to `1` +- `TaskLists._DIRTY` as a monotonic counter, not a flag +- the instances URI ignoring `CALLER_IS_SYNCADAPTER` +- no home for a per-collection sync token, href, ETag or CTag — all four squat + into generic `SYNC1`–`SYNC8` slots +- **read-only collections cannot be represented at all** (`ACCESS_LEVEL` inert) +- sync-adapter delete ignoring the account parameters it forces you to supply +- `Moving` leaving a dual-UID collision +- `ACCOUNT_TYPE` write-once → enabling sync is a full data migration +- Auto Backup restore arming `cleanUpLists` → silent task loss +- `Detaching` deciding the recurring-completion model for us +- the `lib-recur` version trap (0.16.0 removed `RecurrenceSet`) — we pin because + the provider does, not because we want to + +Conservatively that is **2.5–4 weeks** off phases 0, 3 and 4 of the 11.5–15 week +sync plan, plus a class of bug that is currently *unfixable without patching +vendored Java*. + +### Net + +**≈ +1 to +3.5 weeks**, for a store we control, in exchange for two real losses. + +--- + +## The honest case for keeping it (Option A) + +Not nothing, and it should not be waved away: + +- **It works, and it has 56 passing JVM tests** over recurrence, reparenting, + instances and observers. A Room reimplementation is *new code with new bugs*, + in the layer that holds the user's only copy of their data. That risk is real + and it points at A. +- **Tombstones actually work.** Soft delete for account rows, hard delete for + sync adapters, hidden from normal queries, undelete refused. Of all the sync + bookkeeping, this is the piece that held up under audit. +- **The exported provider under our own authority** — third-party apps can read + Agendula's tasks, and asking DAVx5 to sync us stays possible. +- Eleven local modification sites, all marked `AGENDULA CHANGE`, all documented + in `provider/PROVENANCE.md`. The fork is under control today. + +And the case against keeping it: + +- **14,555 lines of Java — 1.66× the entire app** (8,783 lines of Kotlin). We + carry, build, lint, translate and ship all of it to use maybe a third. +- Upstream is effectively dormant; every future `targetSdk` bump and every + Android SQLite behaviour change lands on us, in someone else's code, in a + language the rest of the app does not use. +- It makes behavioural decisions on our behalf (`Detaching`, `AutoCompleting`) + that we then have to reverse-engineer before we can honour them over CalDAV. + +--- + +## Recommendation + +**Option B — build our own store on Room, keeping the `TaskContract` *shape* as +the internal model — and keep `:provider` in-tree while we do.** + +Three reasons, in order of weight: + +1. **The seam already exists and the work is additive.** Nothing is deleted, + nothing above `data/tasks` changes, and both backends can ship side by side + behind `StorageMode`. The "big rewrite" this decision was originally weighed + against does not exist. +2. **The premise that settled it is gone.** The provider was kept for sync + bookkeeping we have since measured as broken. Sixteen findings deep, keeping + it is now a *cost* to the sync plan, not a saving. +3. **The window is now.** After phase 1 the mapper and engine are written against + whichever store won, and this stops being a two-file change. + +Keeping the `TaskContract` *shape* rather than going domain-native (Option C) is +deliberate: it is a proven schema for exactly this problem, other engines +understand it, and it keeps a future exported facade cheap — without obliging us +to run a 2015 Java implementation of it. + +### Sequencing that keeps the risk low + +1. Add `StorageMode.OWN` and a Room `TasksDataSource`. Both backends live. +2. Ship it behind a setting; the vendored provider stays the default. +3. Run the sync engine against Room only. +4. Once Room has real production mileage, decide whether the exported + ContentProvider is worth re-implementing as a thin facade (~1–1.5 wk) or + whether External mode already covers everyone who wanted it. + +Step 4 is a genuinely open question and does not need answering now. That is the +point of sequencing it last. + +--- + +## Open + +- **Is an exported provider worth keeping at all?** It matters only if third + parties should read our tasks, or if we want DAVx5 to sync our store. External + mode arguably already serves the second. Undecided. +- **The Room estimate is mine, not measured.** Instance expansion is the item + that could overrun; everything else is well-bounded. + +--- + +## Postscript: the fork existed, and how it ended + +`provider/PROVENANCE.md` recorded the vendored dmfs task provider in detail. +Both are gone; this is what is worth keeping. + +The module was `opentasks-provider` plus `opentasks-contract` from +[dmfs/opentasks](https://github.com/dmfs/opentasks) **1.4.2**, commit +`49ebf80b1eeee52a611e5a22f24f849852a6255f` (2021-03-21), Apache-2.0, database +version 23. It was vendored in-tree rather than pulled as an artifact because the +permission names are hardcoded in the upstream AAR's manifest, and shipping under +dmfs's own names would have made Agendula and OpenTasks mutually uninstallable +(`INSTALL_FAILED_DUPLICATE_PERMISSION`). In-tree also satisfied F-Droid's +from-source requirement. + +It was deleted in the `feat/own-store` work (`docs/OWN-STORE.md` phase 5) once +Room was the default and every v0.3.x install had been imported. 14,555 lines of +Java left with it. + +**What survives, and why.** `lib-recur` (Apache-2.0, dmfs) is still a direct +dependency — it is what expands recurrences — so dmfs's attribution is still +owed, now through an ordinary third-party dependency rather than vendored source. +`TasksContract.kt` and the External-mode mappers also stay: they describe +*somebody else's* schema, which is exactly what they were always right for. + +**One detail the fork's provenance file carried that still binds us.** DB 23 is +the first to have `is_recurring`; tasks.org's fork is DB 22 and lacks it. That is +why `TaskMapper.task` derives recurrence from `rrule`/`rdate` rather than trusting +that column, and it must keep doing so for as long as External mode supports +tasks.org. diff --git a/docs/SYNC.md b/docs/SYNC.md new file mode 100644 index 0000000..3a1c685 --- /dev/null +++ b/docs/SYNC.md @@ -0,0 +1,1243 @@ +# Agendula — CalDAV sync + +> Design notes for Agendula's own sync adapter. Drafted 2026-08-13; **audited the +> same day** against the platform, the vendored provider source, and the current +> state of every library named. The audit refuted or corrected a substantial part +> of the first draft — the corrections are marked ⚠️ **inline and kept visible** +> rather than quietly rewritten, because most of them are things the next person +> would otherwise assume again. +> +> This is step 5 of [`STORAGE-AND-SYNC.md`](STORAGE-AND-SYNC.md)'s sequencing. +> That document decided **where task data lives**; this one decides **how it gets +> to a server**. +> +> Status: **draft / decision document.** No sync code is built — no `dav4jvm`, +> no `ical4j`, no `AccountManager`, no adapter, nothing in the manifest. Where a +> question is already answered by shipped code, it is marked ✅ and the code is +> named. +> +> ⚠️ **The storage question this document declared settled was reopened, the +> answer changed, and the change has since shipped.** Agendula owns a Room store +> and the vendored provider is deleted — see +> [`STORAGE-DECISION.md`](STORAGE-DECISION.md) and +> [`OWN-STORE.md`](OWN-STORE.md). Roughly sixteen of the findings below are +> **provider-imposed** and went with it; `OWN-STORE.md` § *Effects on the sync +> plan* lists them item by item. They are kept here, marked, because the External +> path still runs on that provider and because the next person would otherwise +> re-derive them. Everything platform-level (the targetSdk 34 sync gate, the stub +> adapter, credential storage, Play compliance) and everything protocol-level +> (discovery, RFC 6578, conditional PUT, conflict policy, the server-reality +> table) is unaffected and remains the plan. +> +> **What owning the store already settled, in shipped code** (2026-09): +> +> | Was a sync deliverable | Now | +> |---|---| +> | Phase 0 — UIDs at creation | ✅ `uid` is `NOT NULL`, minted on insert in both modes | +> | Phase 0 — backup / prune safety | ✅ backup rules cover the WAL, `ON_STOP` checkpoint, restore test | +> | Phase 1 — the recurrence representation | ✅ `RRULE`/`RDATE`/`EXDATE` stored raw, expanded at read (`RecurrenceExpander`), `RECURRENCE-ID` overrides sharing the master's UID | +> | Phase 3 — Local→Synced migration | ✅ **gone**: `task_lists.account_id` is a nullable FK, so attaching an account is one `UPDATE` | +> | Phase 3 — recurring-completion model | ✅ model **(a)** is what the store writes; the provider's model (d) left with it | +> | Sync bookkeeping columns | ✅ `href`, `etag`, `sync_token`, `is_dirty`, `is_deleted` exist in the v1 schema | +> +> Still nothing but design: phase 1's mapper (`ICalendarWriter` writes VTODO for +> **export only** — no parser, no unknown-property round-trip), phase 2 auth, +> phase 3's engine, phase 4 hardening. Phase 0's licence-attribution screen is +> also not built. + +Scope: self-hosted CalDAV first (Nextcloud), F-Droid and Play, MIT license. + +--- + +## The plan, in short + +| Question | Direction | +|---|---| +| Where does task data live? | ⚠️ **Changed since this table was written.** Our own Room store — `:provider` is deleted (`OWN-STORE.md`) | +| Who syncs it? | Agendula, via its own sync adapter | +| Account model | `AccountManager` **+ a real (stub) sync adapter** — ⚠️ the hybrid without one does not work | +| Scheduling | WorkManager, triggered *through* the sync framework | +| Protocol library | `dav4jvm` (MPL-2.0) — ⚠️ costs more than the first draft assumed | +| Self-signed certs | `cert4android` — ⚠️ **MPL-2.0, not GPLv3.** The first draft rejected it on a false premise | +| iCalendar | In-house mapper over `ical4j`; **no `synctools`** (GPLv3). ⚠️ Now VTODO ↔ **Room entities**, not `TaskContract` | +| Recurrence | Read/write `RRULE`/`RDATE`/`EXDATE` directly — ⚠️ **not** the Instances table | +| Primary read path | ⚠️ `REPORT calendar-query` (VTODO filter, no time-range); `sync-collection` is the optimisation | +| Recurring completion | ⚠️ Accept all four models on read; **we write (a)** — the store forks a `RECURRENCE-ID` override sharing the master's UID. The provider's model (d) left with the provider | +| Sign-in | Nextcloud Login Flow v2 + generic CalDAV discovery + Digest | +| Conflict policy | `If-Match`; on 412 the server wins, local copy preserved | +| Reference implementations | jtx Board and DAVx5 — read, never link against (GPLv3) | + +| # | Phase | Deliverable | Effort | +|---|---|---|---| +| 0 | **Groundwork** | ~~UIDs at creation~~ ✅, ~~backup/prune safety~~ ✅, licence-attribution screen ⬜, Java-21 decision ⬜ | ~1 week → days | +| 1 | Mapper | VTODO ↔ **Room entities**, unknown-property round-trip, fixture corpus. ⚠️ `ICalendarWriter` is the export half only — write-only, and it drops what it does not model | 2–3 weeks | +| 2 | Auth | Discovery, Login Flow v2, Digest, credential storage, cert trust | 1.5–2 weeks | +| 3 | Engine | `calendar-query` baseline, `sync-collection` optimisation, full reconciliation, conflicts, scheduling, ~~**Local→Synced migration**~~ ✅ gone — `account_id` is a nullable FK | 4–5 weeks | +| 4 | Hardening | Per-server trap matrix, error UX, re-auth, Play compliance | 2–3 weeks | + +⚠️ **Revised upward from the first draft's 8–9 weeks to 11.5–15**, then back +down. Phase 0 is new; the migration in phase 3 was previously believed not to +exist at all; and the engine grew a second sync path plus a permanent +reconciliation pass. Owning the store then deleted 2.5–4 weeks of it +(`OWN-STORE.md` § *Effects on the sync plan*) — the migration, the recurrence +representation and phase 0's data work are done — leaving roughly **8–11 weeks**. + +**Calibration, for sanity:** Evolution shipped RFC 6578 in **June 2026** against +a request open since 2019. vdirsyncer has declined to implement it for twelve +years. Thunderbird still carries unlanded patches for one of its error paths. +This is not a phase that gets shortened by trying harder. + +--- + +## Settled — the storage question is not reopened here + +A later working draft re-argued storage as a fresh choice between *bundle the +dmfs provider* and *a Room-native store with a `TaskContract` facade*, and +recommended Room. Not adopted; this is the audit trail so it does not come back. + +The provider shipped in `f978c37`. Three of the arguments against it do not +survive contact with the repository: + +1. **"`synctools` is GPLv3 and propagates to the app."** Misattributed. + `:provider` depends on `jems`, `rfc5545-datetime` and `lib-recur` — all + Apache-2.0. `synctools` is a *mapper* choice, equally avoidable either way. +2. **"Cursor access everywhere; the widget pays the Calendula cost."** Calendula + queries `CalendarProvider` **cross-process**; ours is same-package, same-uid, + so `ContentResolver` returns the local provider instance — no Binder hop, no + `CursorWindow` marshalling. The observer→Flow seam already exists. +3. **"The dmfs schema can't hold what we need."** The `Properties` table takes + arbitrary mimetypes — `PropertyHandlerFactory.get` falls through to + `DEFAULT_PROPERTY_HANDLER`, and `PropertyHandler.insert` is a bare + `db.insert` with no validation, over sixteen real `data0`–`data15` TEXT + columns. That is the mechanism for unknown-property round-tripping, this + document's single most important correctness requirement. + +**But the audit also weakened the positive case.** The first draft claimed the +provider hands us the sync bookkeeping for free. It hands us *some* of it, with +sharp edges — see the next section. Room would have been the wrong trade anyway +(it buys Flow ergonomics we already have and costs the tombstone/instance +machinery we already run), but "for free" was too generous. + +--- + +## What the provider actually gives us + +⚠️ **This section is now history for the sync plan.** Every line was verified +against `provider/src/main/java/`, which no longer exists — the vendored provider +is deleted and our own store is what sync will run against. Nothing here +constrains the adapter any more. + +It is kept, not deleted, for two reasons: **External mode still talks to exactly +this code** in OpenTasks and tasks.org, so the app layer still lives with these +rules; and if External mode is ever retired (open question 3), this is the record +of what was being given up. + +| Mechanism | Reality | +|---|---| +| `CALLER_IS_SYNCADAPTER` | Works on `tasks` and `tasklists`. ⚠️ **Ignored on the `instances` URI** — `processors/instances/TaskValueDelegate` hardcodes `false` on every delegation | +| `_DIRTY` on user writes | ⚠️ **Set on insert/update only, never on delete.** `AutoCompleting.delete` skips `updateFields`; `TaskCommitProcessor.delete` sets only `_DELETED` | +| `_DIRTY` default | ⚠️ **Defaults to `1`** (`TaskDatabaseHelper:456`). Every downstream insert must write `_dirty=0` explicitly or it uploads straight back | +| `TaskLists._DIRTY` | ⚠️ **A monotonic counter, not a flag** — a trigger does `_dirty = _dirty + new._dirty + new._deleted` and nothing ever decrements it | +| `_DELETED` tombstones | ✅ Real. Soft-delete for account-backed tasks, hard delete for sync adapters *and* for the local account. Hidden from non-sync queries | +| Instances | ⚠️ **Materialises exactly one upcoming occurrence** (`UPCOMING_INSTANCE_COUNT_LIMIT = 1`), on write only, never re-expanded as time passes. Useless as a recurrence representation for sync | +| `_UID` | ⚠️ **Settable by anyone on insert** — restricted to sync adapters on *update* only. Never generated by the provider | +| `SYNC1`–`SYNC8`, `_SYNC_ID`, `SYNC_VERSION` | Exist and are free — but ⚠️ **`Moving` nulls all of them on a list move while keeping `_UID`**, leaving a live row and a tombstone sharing one UID | +| Per-collection sync state | ⚠️ **Does not exist.** The `SyncState` table is one blob **per account**, `db.replace`d | +| Read-only collections | ⚠️ **Cannot be represented.** `ACCESS_LEVEL` is inert — the contract says "not used yet", and `Validating.java:60` still carries upstream's `// TODO: ensure that the list is writable` | +| Account scoping on delete | ⚠️ The provider **requires** account params on a sync-adapter task delete and then **ignores them** (`TaskProvider.java:793` — upstream `// TODO`) | + +### The consequences, as rules + +1. **The upload query is `_dirty = 1 OR _deleted = 1`.** Deleting is not dirtying. +2. **Every downstream insert sets `_dirty = 0` explicitly.** +3. **Name the squats now**, because none of these columns exist: CTag → + `TaskLists.SYNC_VERSION`; per-task ETag → `Tasks.SYNC_VERSION`; href → + `Tasks._SYNC_ID`; per-collection sync-token → a `TaskLists.SYNC*` slot (the + `SyncState` blob is per-account and cannot hold it). +4. **Never write through the `instances` URI.** Read and write `RRULE`, `RDATE` + and `EXDATE` on `tasks` — they are raw TEXT and round-trip cleanly. +5. **Scope every adapter delete by `list_id` yourself.** +6. **Sequence DELETE before PUT on a list move**, or the two rows sharing a + `_UID` collide on the server. +7. **Read-only collections are enforced in the app layer**, not the provider. +8. **Soft-deleted subtasks lose their `Relation` rows** before the adapter sees + the tombstone (`Reparenting.unlinkParent` runs regardless of `isSyncAdapter`). + Either cache the relation or accept that `RELATED-TO` is unrecoverable on + delete. + +### Two collisions with the shipped app layer + +- **`AndroidTasksDataSource.setAlarm` deletes every alarm property row on the + task** before re-inserting. Once the adapter round-trips `VALARM`s, one local + reminder edit destroys all server-side alarms on that task. Decide ownership: + either the adapter owns `VALARM`s and the app stops bulk-deleting, or reminders + stay local-only and are never serialised. +- **`updateInstance` routes through the instances URI**, which per the table + above is permanently non-sync-adapter and forks override rows. Those overrides + arrive `_dirty=1` with no `_uid`/`_sync_id` and must be uploaded as + `RECURRENCE-ID` components. + +--- + +## ⚠️ The migration that was believed not to exist — and then stopped existing + +**Resolved: the original assertion is true again, because the constraint that +broke it was the provider's.** Our own `task_lists.account_id` is a nullable FK +from v1, so attaching an account to a list is one `UPDATE` and no task moves. +Phase 3 does not carry this deliverable. The rest of this section is the record +of why it was believed to, and still describes External mode exactly. + +The first draft, `STORAGE-AND-SYNC.md` and `ARCHITECTURE.md` all asserted: +*"Synced is Local with an account attached, so switching on sync is not a +migration."* **That was false against the dmfs provider.** + +`processors/lists/Validating.java:68-76` throws on any attempt to change a task +list's `ACCOUNT_NAME` or `ACCOUNT_TYPE` — both are write-once, and the contract +documents them as such. Local lists live under `org.dmfs.account.LOCAL` and can +never be re-pointed at a real account. + +Turning on sync therefore means, as a real deliverable with its own tests: + +1. Create new lists under the account (sync-adapter insert; account params come + from the **URI**, never the values, and are frozen thereafter). +2. `UPDATE list_id` on every task — this *is* permitted, and it is the one path + the provider gives us. +3. Assign `_UID`s (or better: have them already, see phase 0). +4. Delete the old local lists (sync-adapter only). +5. Re-point `DEFAULT_LIST_ID`, per-list reminder overrides in DataStore, and + every scheduled alarm. + +Not modelling `SYNCED` as a third `StorageMode` is still right — it is derived +state. But the migration it implied away is real, and step 2's `Moving` processor +nulls `_sync_id`/`sync_version`/`SYNC1`–`SYNC8` and clones a tombstone as it goes. + +**Alternative worth considering:** don't migrate. Synced lists are always *new* +lists, and moving local tasks into them is an explicit user action with a visible +UI. Cheaper, more honest, and it never silently rewrites the user's data. + +--- + +## Account model — and the trap under it + +**Decision: `AccountManager` with our own account type, plus a registered +`` service whose entire job is to enqueue a WorkManager job.** + +⚠️ **The first draft's justification was circular** and its architecture did not +work. Both corrected: + +### The justification + +The draft argued AccountManager was *required* because the provider prunes lists +whose `ACCOUNT_TYPE` has no authenticator in this package. That is backwards — +`PROVENANCE.md` change 1 made the prunable set **empty by construction** +precisely so that shipping no authenticator prunes nothing. The provider imposes +no requirement at all. tasks.org proves the alternative: no AccountManager, Room +accounts, pure WorkManager, `GET_ACCOUNTS` stripped with `tools:node="remove"`. + +The real reasons, which are still good ones: a stable account identity that +third-party engines can address (the DAVx5 ask, open question 5), presence in +system Settings, and the sync framework as a change-trigger. + +### The trap ⚠️ + +`ContentService.hasAuthorityAccess()` gates `requestSync`, `setSyncAutomatically`, +`addPeriodicSync`, `setIsSyncable`, `getSyncStatus` and seven more behind a +compat change `@EnabledAfter(TIRAMISU)` — **on for targetSdk ≥ 34**, which we +are. With no package registering a sync adapter for our authority, every one of +those calls **returns silently**: no exception, no log. It is documented on no +Android behaviour-changes page. + +So "AccountManager accounts for visibility + WorkManager for scheduling + no sync +adapter" — exactly what the first draft described — yields: + +- every `ContentResolver` sync API a no-op that passes on a Robolectric shadow, +- an account in system Settings permanently reading **"Sync off for all items"**, +- a **greyed-out "Sync now"**, because `enabledSyncNowMenu()` needs at least one + checked authority switch. + +**Fix:** register a real `AbstractThreadedSyncAdapter` whose `onPerformSync` +enqueues a WorkManager job and waits — DAVx5's own comment: *"We use the sync +adapter framework only for the trigger, actual syncing is implemented with +WorkManager."* Declare `READ_SYNC_SETTINGS` / `WRITE_SYNC_SETTINGS`. Ship an +in-app sync button too, since "Sync now" stays greyed out under +`userVisible="false"`. + +**Free trigger already running:** `TaskProvider.syncToNetwork()` returns `true` +unconditionally, so `SQLiteContentProvider` already fires +`notifyChange(uri, null, syncToNetwork=true)` on every user write. The system is +*already* requesting a sync for our authority on each edit — there is simply +nothing registered to receive it. + +### ⚠️ Auto Backup will arm `cleanUpLists` into a data-loss path + +✅ **Closed.** Both rule sets are now explicit and name our own database with its +WAL sidecars, the app checkpoints on `ON_STOP`, and a restore test covers the WAL +case in both directions (`OWN-STORE.md` phase 6). `cleanUpLists` was the +provider's, and left with it. The original finding, which still describes what an +External-mode user's provider app does: + +`backup_rules.xml` and `data_extraction_rules.xml` are both **empty rule sets**, +and `allowBackup="true"`. An empty set means Auto Backup's default: databases +included. So the provider's `tasks.db` is backed up and restored — while +AccountManager accounts, which live in `/data/system_ce/`, are not. + +`TaskProvider.onCreate` registers `addOnAccountsUpdatedListener(…, updateImmediately=true)`. +On the first callback after a restore, `cleanUpLists` sees lists carrying our +`ACCOUNT_TYPE` with no matching account and **deletes them, and their tasks, +silently** — cascading through `task_list_cleanup_trigger`. Exactly the failure +`PROVENANCE.md` change 1 exists to prevent, reintroduced from behind. + +Latent today (no authenticator ⇒ nothing prunable); live the day phase 3 ships. + +**Do not fix this by excluding the database from backup.** That was the audit's +suggestion and it is wrong for us: Local-mode data lives in exactly one place, +and Auto Backup is currently its only automatic safety net — removing it to +protect *synced* lists would trade a latent bug for a live one. Fix it at the +cause instead: + +1. Make pruning **event-driven** — react to `AccountManager`'s account-removed + broadcast, not to "absent from the visible set". +2. Add a post-restore reconciliation that offers to **re-attach the account** + rather than deleting. +3. Exclude only the **Keystore-encrypted credential blob** from backup — a + restored ciphertext is permanently undecryptable, since Keystore keys are + non-exportable. +4. Device-verify: restore a backup onto a fresh device, confirm nothing is pruned. + +--- + +## VTODO ↔ `TaskContract` + +In-house, behind an interface, so the iCalendar library stays swappable. + +### Unknown properties: the one non-negotiable + +Any `X-` property, unrecognised component or parameter written by another client +**must survive a read-modify-write cycle unchanged.** Failing this silently +destroys other people's data and is invisible in our own UI. + +Storage exists: a `Properties` row with our own `unknown-property` mimetype and +the serialised property in `DATA0`. Verified: no validation rejects an unknown +mimetype, the only index is non-unique so repeats are fine, and there is no FTS +interaction (`updateFTSEntry` is called only from `CategoryHandler`). Note +`MIMETYPE` is *declared* `INTEGER` while holding strings — harmless under SQLite +affinity, but don't be alarmed by it. `Tasks.HAS_PROPERTIES` is never set by +anything; do not filter on it. + +⚠️ **But "byte-stable" is unachievable as the draft stated it, and stating it that +way is dangerous** — the corpus would fail on day one and then be normalised +until the only thing that matters, *no unknown property is dropped*, is no longer +tested. Six independent reasons a faithful implementation cannot be byte-identical: +`PRODID` **must** change (emitting another product's is a lie); fold position +carries no information and a line may split between any two characters; parameter +quoting is optional (`TZID=Europe/Berlin` ≡ `TZID="Europe/Berlin"`); property +order within a component is unconstrained, and storing known fields as columns +destroys the original interleaving **by construction**; `DTSTAMP` is regenerated +and `VTIMEZONE` re-emitted; and the server will not return what we sent anyway. + +**Restate it as a semantic round-trip with byte-stable property values.** Re-parse +both sides into a canonical multiset of +`(component path, property name, params as a sorted map, unfolded unescaped value)` +and assert equality **modulo an explicitly enumerated allowlist** — `PRODID`, +`DTSTAMP`, `LAST-MODIFIED`, `SEQUENCE`, VTIMEZONE bodies, fold positions, +parameter quoting. Nothing else may differ. Byte-equality is then asserted where +it means something: the **unfolded, unescaped value octets** of every untouched +property, plus full parameter preservation including unknown parameters. RFC 5545 +§3.1 is the requirement being encoded: *"Applications MUST preserve the value data +for x-name and iana-token values that they don't recognize."* + +⚠️ **And the requirement is necessary but not sufficient.** A client that +round-trips perfectly, passing every fixture, still destroys the owner's data if +it PUTs back a body Nextcloud filtered on the way out (see +[Server reality](#️-server-reality--the-matrix-audited)). Never write back a body +whose ETag does not match the hash of what we downloaded. + +**Three gaps in the storage sketch.** A flat property-per-row model does not +represent unknown properties **nested inside a known sub-component** (an `X-` prop +on a `VALARM`), or entirely unknown components, or RFC 9074's alarm properties +(`ACKNOWLEDGED`, `PROXIMITY`) which are what other clients now write. Decide +between an opaque sub-component blob and reconstructing nesting from +`DATA0`–`DATA15`. And **set a size cap** — DAVx5 drops unknown properties above +~25 kB — for two reasons: Android's `CursorWindow` row limit, and +`CALDAV:max-resource-size`, whose violation is a failed PUT. + +**Test-first.** The corpus comes before the mapper, and the fixtures that catch +bugs are the adversarial ones, not a clean server-generated VTODO: a +`CLASS:CONFIDENTIAL` task fetched from a **shared** Nextcloud calendar; a resource +carrying a master plus `RECURRENCE-ID` overrides; unknown properties nested inside +a `VALARM`; a `TZID` the device's tzdb does not know; a UID containing `/` and +`@`; and one exceeding `max-resource-size`. + +### The rest of the minefield + +- `DUE` vs `DTSTART`; `VALUE=DATE` vs `DATE-TIME`; floating times and `TZID`. + Match the all-day/UTC convention `fix/provider-interaction-review` established. +- **`STATUS` / `PERCENT-COMPLETE` / `COMPLETED` disagree across clients.** Pick a + canonical reading, normalise on write only. Local convention to reconcile + against: the edit form writes `PERCENT_COMPLETE` clamped 0–100 and leaves + `STATUS` to the complete toggle. +- `RELATED-TO` for subtask trees, including orphans in another collection. The UI + nests one level; the *data* must not assume it. +- `VALARM` — see the `setAlarm` collision above before writing a line of this. +- `CATEGORIES`, `PRIORITY` (**0 = undefined, 1 = highest**). +- ⚠️ **`SEQUENCE` is preserve-verbatim, not ours to bump.** It is the *Organizer's* + revision counter (§3.8.7.4); a client that increments it on every save confuses + scheduling-aware peers. Related: **`DTSTAMP` is regenerated per serialisation, + `LAST-MODIFIED` changes only when the data actually did.** Conflating them makes + every sync look like an edit. +- ⚠️ **`COMPLETED` MUST be UTC** (§3.8.2.1) — no TZID, no floating, no DATE. +- ⚠️ **A `VALARM` with `TRIGGER;RELATED=END` requires `DUE`, or `DTSTART` plus + `DURATION`** (§3.8.6.3). A user clearing the due date on a task that has an + end-relative reminder produces an invalid resource, permanently rejected. Validate + before PUT — this is reachable from ordinary UI actions. +- ⚠️ **`RELATED-TO;RELTYPE` reads backwards to most implementers.** §3.8.4.5: + `PARENT` means *the referencing component is subordinate to the referenced + one*. Both Nextcloud Tasks and tasks.org put `RELTYPE=PARENT` on the child + pointing up — that is the correct reading. Also: the RFC explicitly disclaims + cascade semantics, so "completing a parent completes its subtasks" is a local + UI convention peers will not reproduce. +- ⚠️ **Round-trip `X-MOZ-LASTACK` / `X-MOZ-SNOOZE-TIME` unmodified.** Dropping + them causes documented **alarm storms** on Thunderbird. Emit RFC 9074 + `ACKNOWLEDGED` for our own writes rather than minting `X-MOZ-*`. Preserve + `X-APPLE-SORT-ORDER`; never interpret it. + +### ⚠️ Recurring completion — and the model our provider already chose + +The draft said "no standard; choose one". That is **confirmed and understated**, +and the choice is less free than it looked. + +**How settled the non-standard is.** `draft-ietf-calext-ical-tasks-17` — the +active IETF work item whose entire purpose is extending VTODO, `Updates: RFC5545` +— contains the substring **"recur" zero times** in 1,904 lines. It adds +`SUBSTATE`, `REASON`, `TASK-MODE` and a `VSTATUS` component and leaves this +untouched. RFC 8984 §5.2.6 (JSCalendar) is the only RFC that addresses it at all, +and it **blesses two mutually incompatible approaches and declines to pick**. +RFC 5545 permits `COMPLETED` on a recurring master with no interaction rule — +undefined, which is worse than forbidden, because every client picks differently +and all stay conformant. + +The four models in the wild: + +| | Model | Who | +|---|---|---| +| **a** | Write a `RECURRENCE-ID` override; master stays open | jtx Board, Thunderbird, eM Client | +| **b** | Advance the master's `DUE`/`DTSTART` in place, clear completion | tasks.org, Evolution, Nextcloud Tasks *in practice* | +| **c** | `STATUS:COMPLETED` on the master — kills the series | Nextcloud Tasks ≤ 0.17. **Always a bug** | +| **d** | Detach the completed occurrence as a **new task with a new UID**, and advance the master | **OpenTasks — i.e. our `:provider`** | + +**The finding that matters to us: `:provider` has already chosen model (d).** +`processors/instances/Detaching.java` nulls `_UID`, `_SYNC_ID` and every +`ORIGINAL_INSTANCE_*` on the detached row, and `detachAll` advances the master +and decrements `RRULE;COUNT`. dmfs did this deliberately — on the record, *"the +primary reason is to support Apple clients; they don't support overrides"*. So +our storage layer emits a UID-less orphan plus a moved master, and our sync +adapter has to either honour that, bypass the processor, or reconcile after it. +**That is a design constraint we inherited without deciding it**, and it is the +strongest single argument for treating this as a phase-1 decision rather than a +phase-4 detail. + +**Rules the audit establishes, regardless of which model we write:** + +- **Never write model (c).** Every instance found was filed as a defect; + Thunderbird fixed it fifteen years ago. +- **Never do (a) and (b) together.** `RECURRENCE-ID` is *defined* as the + instance's original `DTSTART`, so advancing the master orphans your own + override. Nextcloud Tasks 0.18 attempts exactly this — and is saved only by an + accident: its override write dispatches a Vuex action that **does not exist**, + which Vuex 4 swallows without throwing. Shipped behaviour is therefore silent + model (b) with no record the instance was ever completed. +- **Accept all four models on read, unconditionally**, including an inbound + master whose `DUE` moved and whose `COMPLETED` vanished. That is not corruption. +- **Never abort a sync batch on a multi-VTODO resource.** tasks.org returns from + its whole sync function on one — so a single Thunderbird-completed recurring + task **stops that entire collection from syncing**, ctag never advances, and + every other change in the batch is silently lost. Degrade to the master and + continue. +- **Keep overrides in the same calendar object resource** (RFC 4791 §4.1). +- ⚠️ **`RRULE` + `DUE` with no `DTSTART` has no well-defined `RECURRENCE-ID` + value** — undefined in RFC 5545, ubiquitous in the wild. Synthesising + `DTSTART := DUE` is the common workaround and is itself the source of visible + DTSTART/DUE desync between clients. Handle it explicitly. +- ⚠️ **Repeat-from-completion has no interoperable encoding at all.** Either drop + it or document that peers read it as repeat-from-due. +- Consider tasks.org's escape hatch: a per-account **"let the server schedule + recurring tasks"** switch. + +~~Open question 4~~ — **decided and shipped: we write (a).** We do own the whole +path now, so the `Detaching` caveat is moot. `RoomTasksDataSource`'s +`setCompletedInstance` and `updateInstance` both fork a `RECURRENCE-ID` override +sharing the master's UID, and the master stays open — jtx Board's and +Thunderbird's model, and the one that maps onto CalDAV without invention. The +adapter must still **read** all four models, which is unchanged. + +> **Process note.** During this research a summarising fetch **fabricated a +> verbatim RFC 5545 sentence** ("A 'to-do' calendar component without the +> 'dtstart' property MUST NOT be part of a recurring set") that appears nowhere +> in the RFC — grep confirms zero hits — along with an invented DTSTART/DUE +> exclusivity rule. Normative text gets read from the raw RFC, never from a +> summary. Both fabrications would have inverted a design decision here. + +--- + +## Libraries + +⚠️ **The first draft's table had one outright licence error and understated two +libraries by roughly an order of magnitude.** Re-verified 2026-08-13 against +published POMs, Gradle module metadata and extracted jars. + +### Take + +| Library | Licence | Real cost | +|---|---|---| +| **dav4jvm** 4.0.1 | MPL-2.0 | ⚠️ **Ktor-only** (the OkHttp package was deleted in 3.0.0); ⚠️ **requires Java 21 bytecode** — we target 17 everywhere, including all seven floret-kit modules; pulls Ktor (~2.45 MB), `guava-jre` (wrong flavour — force `-android`), and `xpp3` (371 KB, duplicates framework `org.xmlpull.v1`). The library itself is only 433 KB / 246 classes | +| **cert4android** | ⚠️ **MPL-2.0 — not GPLv3** | Same org, same licence, same JitPack question as dav4jvm. See below | +| **ical4j** 4.3.0 | BSD-3-Clause | ⚠️ Not "needs desugaring" — `java.time` is native at minSdk 26 and we are 29. Real costs: **2.2 MB of duplicated tz data** in the jar (`zoneinfo/` *and* `zoneinfo-global/`, ~596 `.ics` each), a `ZoneRulesProvider` that pre-allocates 1500 synthetic zone IDs **and exhausts in production**, and a mandatory `ical4j.properties` + `MapTimeZoneCache` + registry shim | +| **lib-recur** | Apache-2.0 | ⚠️ **Version trap, see below** | + +### ⚠️ cert4android was rejected on a false premise + +The first draft listed it as GPLv3 and budgeted a hand-rolled trust-on-first-use +dialog instead. **It is MPL-2.0** — verbatim MPL text in `LICENSE`, SPDX +boilerplate in the README, GitHub agrees. The same licence as dav4jvm, which the +same document accepts two rows above. Two independent audit tracks caught this. + +That matters because the hand-rolled version is not a dialog: + +- **A background sync has no UI to show a dialog in.** cert4android's bound + service + notification approval *is* the library, not an accessory to it. +- **Network Security Config cannot express runtime trust** — it is a static + manifest resource. And since API 24, user-installed CAs aren't trusted without + an NSC entry, so "tell the user to install their CA" fails too. +- **The 3-arg `checkServerTrusted(chain, authType, host)` is mandatory**; + 2-arg-only TrustManagers have repeatedly broken on OkHttp. +- **Hostname verification is a second override** and the other thing Play flags. +- **Play has blocked publishing on unsafe `X509TrustManager` since 2016**, and + the guidance explicitly names "buggy or incomplete custom verification". + +This roughly dissolves the self-signed-cert line item in phase 4. + +### ⚠️ lib-recur is a version trap, not a free dependency + +✅ **Resolved by deleting the other side of the trap.** `:app` declares lib-recur +0.12.2 directly and `RecurrenceExpander` uses it; the vendored provider whose +iterators would have stopped compiling no longer exists, so the version is ours +alone to move. The `RecurrenceSet` removal in 0.16.0 is now a plain upgrade +question, not a build-breaking one. The original finding: + +The first draft said "already in the build at 0.12.2 — no new dependency". Both +halves are wrong. `provider/build.gradle.kts:56` declares it `implementation`, not +`api`, so it is **not** on `:app`'s compile classpath. And lib-recur **0.16.0 +removed `RecurrenceSet`**, which the vendored provider uses in +`TaskInstanceIterable`/`TaskInstanceIterator`. The moment `:app` adds a current +lib-recur, Gradle's highest-wins resolution upgrades the graph and **the provider +stops compiling.** + +Decide explicitly: pin `strictly = "0.12.2"` and accept the known fixes we forgo +(0.15.1 `FastForwarded`, 0.15.2 empty-`ByDay`), or budget the iterator rewrite +onto the post-0.16 API. Note upstream is dormant — last release 0.17.1, last +commit 2024-04. + +### Consider + +**biweekly** (BSD-2) is stronger than the first draft credited: 639 KB / 432 +classes, **no bundled tz database at all**, legacy `java.util.Date` so no +`ZoneRulesProvider` hazard — against ical4j's 2.2 MB of zone data and its +registry shim. Caveats: last release 0.6.8 (2024-01), still 0.x, mandatory +`jackson-core` for jCal (excludable), and the missing tz database means it relies +on `VTIMEZONE` components being present rather than resolving `TZID`s itself — +a real gap for CalDAV round-tripping. + +### Do not take + +| Library | Why not | +|---|---| +| **synctools** | **GPLv3.** Does exactly the mapping we need against exactly the schema we run, which makes it the sharpest temptation here. Still a one-way door. (Repo now archived and folded into `davx5-ose` as a module — the standalone coordinate is stale.) | +| **Android-SingleSignOn** | ⚠️ **GPL-3.0.** The first draft carried it as a harmless optional extra; it is the actual one-way door. It also proxies through the Files app and supports only OCS plus a few WebDAV verbs — not a CalDAV transport | +| **caldav4j** | Apache-2.0, but server-oriented, last release 2022-01 | +| **sardine** | Needs JAXB — a non-starter on Android | + +**Still true:** there is no mature Kotlin-native iCalendar library, and no +Android-suitable CalDAV client on Maven Central at all. That is *why* the JitPack +question is unavoidable rather than optional. + +### On GPL and Play + +The rejection reasoning is right in effect but was imprecise. MIT **is** +GPL-compatible; the constraint is on the terms of the distributed binary, not on +our source headers. And ⚠️ **GPLv3 is not a Play problem** — DAVx5 is GPLv3 and +ships on Play with 287k+ installs; §6 Installation Information is a hardware +provision. If the real reason is wanting Agendula to stay permissively +relicensable, say that, because that is the reason that holds. + +### ⚠️ Licence obligations we cannot currently discharge + +Settings exposes only our own MIT `LICENSE`. There is no third-party attribution +surface and no AboutLibraries in the build. But MPL-2.0 §3.2(a) requires telling +recipients how to obtain source; §3.4 requires retaining file headers; **BSD-3 +requires reproducing the copyright notice in binary distributions** (that is +ical4j, and it is not optional); Apache-2.0 §4(d) propagates NOTICE. Shipping any +of these without an attribution screen is a plain violation, independent of +copyleft. **Phase 0 work item.** Bonus trap: ical4j's POM declares a non-SPDX +licence name and a `LICENSE` URL that 404s, so generators produce empty output. + +### The JitPack question, restated + +`dav4jvm` and `cert4android` are both `com.github.bitfireAT:*` — JitPack only. +F-Droid's inclusion policy does trust jitpack.io for freely-licensed artifacts, +so **F-Droid is not the obstacle; our own `FAIL_ON_PROJECT_REPOS` policy is.** +But F-Droid's own writing is lukewarm — JitPack "hosts whatever is built from +GitHub, without checking the license" — and concretely: JitPack **does not sign +artifacts** (`.asc` 404s; Maven Central's does not), and rebuilds on demand, so a +coordinate is not immutable. We have no `verification-metadata.xml` today. + +Weigh against that: **dav4jvm shipped two breaking majors nineteen days apart** +(3.0.0 OkHttp→Ktor 2026-07-08; 4.0.0 callbacks→coroutines 2026-07-27), which +argues for vendoring a known-good tree rather than a floating pin — and at 433 KB +vendoring is far cheaper than depending, once the Ktor/guava/xpp3 tail is counted. +Open question 1. + +--- + +## Authentication and discovery + +### Nextcloud Login Flow v2 + +The protocol description survives audit against the server source: the endpoint, +the `{poll:{token,endpoint},login}` shape, 404-until-approval, the 20-minute +lifetime (`lifetime = 1200` in `LoginFlowV2Mapper.php`), and "the 200 is returned +exactly once" (the mapper deletes the row inside `poll()` before returning). It is +not deprecated, there is no v3, and OAuth2 is a worse fit. Corrections: + +- ⚠️ **Poll with `POST`, form-encoded.** A `GET` gets 405. The draft didn't say. +- ⚠️ **Set an explicit `User-Agent`.** `init()` passes it to `createTokens()`, + where it becomes the app password's **name** in Settings → Security → Devices & + sessions. With OkHttp's default the user sees `okhttp/4.12.0` and cannot tell + what to revoke — defeating the entire point of the flow. (`OCS-APIRequest` is + *not* needed here; v2 is a Frontpage route.) +- ⚠️ **404 only means pending.** "Treat anything that isn't a 200 as pending" + swallows 429 (brute-force protection), 503 (maintenance), Cloudflare challenge + pages (200 with HTML), and DNS/TLS failure — turning a diagnosable error into a + 20-minute spinner. Require `Content-Type: application/json` before parsing. + Stop polling on anything that is neither 404 nor 200. Note 404 is *also* + returned for expired/consumed, so keep tracking the deadline locally. +- ⚠️ **Validate the `endpoint` origin.** Verbatim is right for the *path*, wrong + as a blanket rule: it is generated from `overwrite.cli.url` / `overwriteprotocol` + / `trusted_proxies`, misconfigured on a large fraction of self-hosted installs. + Refuse a scheme downgrade to `http` outright — the poll token is exchanged for a + long-lived app password, so this is a credential-grade secret. If the host + differs from the one the user typed, confirm explicitly and say *"your server's + `overwrite.cli.url` is wrong"*, which saves a support round-trip. (The draft's + "some deployments return 302" is a proxy symptom, not a Nextcloud variant.) +- ⚠️ **`loginName` is not the uid.** It is what the user typed — possibly an + email, an LDAP-derived value, or the right name in the wrong case. Use it + **only** as the Basic auth username; never interpolate + `remote.php/dav/calendars//`. Discover via `current-user-principal` + → `calendar-home-set`, exactly as the generic path already does. This is the + classic "logged in but no calendars" bug. +- ⚠️ **Custom Tabs needs four things the draft omitted:** a `` entry for + `android.support.customtabs.action.CustomTabsService` (or provider detection + silently fails on API 30+); a try/catch with an `ACTION_VIEW` fallback + (`launchUrl` throws `ActivityNotFoundException` with no Custom Tabs browser — + realistic on GrapheneOS/CalyxOS/AOSP, i.e. disproportionately our users); + persistence of `{token, endpoint, deadline}` to disk immediately, so process + death mid-flow is resumable; and an explicit "I finished / Cancel" affordance, + since Custom Tabs return **no result** when dismissed and Nextcloud's flow ends + on a "you can close this window" page that never returns to the app. + +### Generic CalDAV discovery + +⚠️ **The draft's five steps were the happy path of a much longer pipeline, and +one of its two filters was inverted.** Corrected version, with live probes run +2026-08-13: + +``` +1. Input: email / mailto: / http(s) URL +2. Base URL typed → PROPFIND Depth:0 on it first (principal, home-set and + collection can all come back in one response) +3. Else: + a. SRV _caldavs._tcp. — honour RFC 2782 priority/weight, + honour non-443 ports, target "." = none + b. TXT _caldavs._tcp. — parse path= ⚠️ MISSING FROM DRAFT + c. ladder: [TXT path] → /.well-known/caldav → / ⚠️ "/" MISSING +4. PROPFIND Depth:0 for DAV:current-user-principal + - follow 301/302/303/307/308, re-sending PROPFIND and its body + - relative Location; reject HTTPS→HTTP; cap at 5; PERSIST 301/308 + - 401 → authenticate and retry. NOT a failure ⚠️ MISSING + - reject ⚠️ MISSING + - OPTIONS gate: DAV: header must contain calendar-access ⚠️ MISSING +5. PROPFIND Depth:0 on the principal for calendar-home-set + → iterate ALL hrefs (0..n); cross-host is normative ⚠️ DRAFT ASSUMED ONE +6. PROPFIND Depth:1 per home set, requesting properties BY NAME + → optionally recurse one level into plain {DAV:}collection members +7. Classify: + a. resourcetype as a SET; require CALDAV:calendar ⚠️ MISSING + b. VTODO test: property ABSENT ⇒ INCLUDE ⚠️ DRAFT INVERTED IT + c. privilege-set absent ⇒ assume writable; handle 403 on write +8. sync path: supported-report-set → RFC 6578 keyed on DAV:sync-token +9. Creation: OPTIONS feature-detect → MKCALENDAR / extended MKCOL / disable UI +``` + +**The two filter corrections, which are the important part:** + +- ⚠️ **`supported-calendar-component-set` absent means "supports everything", + not "supports nothing".** The draft kept only collections whose set *includes* + VTODO, which silently drops every server that doesn't advertise it. RFC 4791 + §5.2.3 also says the property SHOULD NOT come back from an allprop request — + so **request properties by name**, or you get none of them. Its grammar is + `(comp+)`; an empty element is non-conformant, and `dav4jvm`'s parser starts + all-`false` and would classify it as supporting nothing. Treat empty as all. +- ⚠️ **Classify on `resourcetype`, with a positive test.** The draft had no + resourcetype check at all, so a Depth:1 listing on Nextcloud yields inboxes, + outboxes, notification collections, trash bins and subscriptions as "task + lists". The test must be **`CALDAV:calendar` is present in the set** — *not* + exclusion by `schedule-outbox`, because **SOGo's main personal calendar reports + `collection` + `calendar` + `schedule-outbox` simultaneously** for every + non-Apple client, which is exactly what we are. The positive rule also keeps + shared calendars (which add `CS:shared` alongside `CALDAV:calendar`) and drops + Nextcloud's `nc:deleted-calendar`, which deliberately strips `caldav:calendar`. + Treat resourcetype as an **unordered set**, never by position. + +**Discovery traps, live-probed:** + +| Trap | Detail | +|---|---| +| SRV TXT `path=` | Live at **Posteo** (`path=/`), **GMX** and **Web.de** (`path=/begenda/dav/users/`). Skip it and GMX/Web.de land on the wrong path | +| Posteo | **SRV-only**, on port **8443**; its `/.well-known/caldav` 404s. Hardcoding 443 fails | +| Null SRV target | `_caldav._tcp.fastmail.com` and `runbox.com` return `0 0 0 .` — "explicitly unavailable" | +| Google SRV | Returns a valid record pointing at `calendar.google.com`, which **is not a DAV server** (PROPFIND → 405). A strict RFC 6764 client follows it into a dead end for every `@gmail.com` | +| well-known 401 | **iCloud and Zoho** answer 401 — the endpoint *is* the DAV root and wants auth. RFC-legal; must not be read as failure | +| Redirect downgrade | `dav.runbox.com` redirects **HTTPS→HTTP** in production today | +| Method preservation | A generic HTTP stack may legally downgrade 301/302 to GET, silently breaking PROPFIND | +| `` | RFC 5397 §3 — a **200** whose body means auth failed. Without this check a failed login looks like a successful discovery that found nothing | +| Cross-host home set | Normative (RFC 4791 §6.2.1's own example), and iCloud depends on it: principal on `caldav.icloud.com`, home set on `pNN-caldav.icloud.com`. Allow it, require HTTPS, surface the host change, never send credentials into an unvalidated redirect chain | +| `caldav.fastmail.com` | `d.fastmail.com` is dead — cert mismatch | + +⚠️ **Two `dav4jvm` defects we would inherit:** it handles 301/302/307/308 but +**not 303**, which RFC 6764 §5 names explicitly; and issue #209 — `location` is +mutated in place, so **permanent redirects never reach the caller**. DAVx5 never +rewrites its stored collection URL after a 301 and re-follows on every sync. +Persist the new URL ourselves on 301/308. + +**Read-only detection is softer than the draft assumed.** RFC 3744 §3.7 defines +a `DAV:read-current-user-privilege-set` privilege, so a server may legally return +`current-user-privilege-set` in a **403 propstat**. Default to writable when it is +absent (as DAVx5 does), expand aggregates yourself (`DAV:write` and `DAV:all` +imply content-write; some servers don't expand), and prefer sabre's +`{DAV:}share-access` where offered as the cleanest signal. + +**Creating lists is not always possible.** MKCALENDAR is only *RECOMMENDED* by +RFC 4791 §5.3.1. **iCloud is MKCOL-only** (and MKCOL under `…/calendars//` +returns 412 while `…///` returns 201); **Google has neither**; +**Posteo disables it** despite running sabre. Feature-detect via OPTIONS and +**disable the "new task list" UI** where neither is available. Set +`supported-calendar-component-set` **at creation — it is protected afterwards**, +and follow up with an explicit PROPPATCH for `displayname`, which most servers +ignore in the MKCALENDAR body. + +**Two more VTODO viability landmines:** Zimbra and OX/mailbox.org restrict tasks +to dedicated task lists (MKCALENDAR with `[VTODO]`), and **OX rejects recurring +VTODOs with 400**. And **server-side `CALDAV:expand` on VTODO is broken on +Nextcloud, Baïkal, SOGo, Radicale and Posteo alike** — expand client-side, which +`lib-recur` already gives us. Request `max-resource-size` too: violating it is a +failed PUT, and long `DESCRIPTION`/`ATTACH` payloads reach it. + +⚠️ **`getctag` is not a cheap pre-check** — `caldav-ctag-03` deprecated it in +2015 in favour of RFC 6578, and `DAV:sync-token` is itself PROPFIND-able, so the +same pre-check comes back in the Depth:1 listing we already make. On Nextcloud +they are literally the same value. Keep `getctag` only as a legacy fallback where +`supported-report-set` omits `sync-collection`. + +⚠️ **Auth is not just Basic:** + +- **Baïkal defaults to Digest** (`dav_auth_type`), and **OkHttp has no Digest + support** — square/okhttp#205 has been open for years. DAVx5 carries a + hand-written `BasicDigestAuthHandler` in dav4jvm precisely for this. Take it + (MPL-2.0, same decision as above) or detect the `WWW-Authenticate: Digest` + challenge and emit a real error instead of "wrong password". Baïkal is squarely + in our target audience. +- **Send Basic preemptively via an `Interceptor`**, gated to HTTPS and the + account's own origin. OkHttp's `Authenticator` is reactive-only — an extra round + trip on every request of a PROPFIND-heavy sync, and it never fires at all on + servers that answer 403/404 without a challenge. Note OkHttp strips + `Authorization` on cross-host redirects (correct, but it breaks `.well-known` + discovery across hosts — re-attach only after validating the target). +- **Fastmail requires an app password** and its Basic plan has no CalDAV at all. + **iCloud requires an app-specific password** and 2FA to mint one. **Google is + OAuth2-only** — refuse it with an explanation rather than a 401. Detect these + by domain at account-add time; "wrong password" that is actually "you used your + account password" is the single most common support ticket any CalDAV client + inherits. + +### Credential storage, rotation, revocation + +⚠️ `androidx.security:security-crypto` is not "effectively stalled" — it is +**formally deprecated and terminal**: deprecated at 1.1.0-alpha07 (2025-04), +shipped deprecated in stable 1.1.0 (2025-07), with release notes saying there +will be no subsequent releases. Its successor `datastore-tink` is alpha only. + +⚠️ And the alternative is weaker than implied: **AccountManager stores passwords +as plain `TEXT`** — no encryption or hashing anywhere in AOSP. FBE plus a +same-signature check is the whole boundary. That is DAVx5's actual posture and is +defensible, but state it rather than implying it is secure storage. + +**Decision:** Keystore `AES/GCM/NoPadding`, blob in DataStore. +`setUserAuthenticationRequired(false)` is the default — don't call it. Do **not** +set `setUnlockedDeviceRequired` (breaks background sync). Handle +`AEADBadTagException` / `KeyPermanentlyInvalidatedException` as *re-authenticate*, +not as a crash. Note `getUserData` returns null while the device is locked, so a +boot-triggered sync must wait for unlock. + +⚠️ **Revocation is bidirectional and the draft had neither direction:** + +- **On 401: stop syncing that account immediately**, mark `NEEDS_REAUTH`, notify + with a deep link into the login flow, and **do not retry on a timer**. + Nextcloud's brute-force protection throttles then 429s **per source IP** — a + retry loop on a dead app password takes down the user's *other* Nextcloud + clients on that network and looks like we broke their server. App passwords do + die in the wild (password change, admin revocation, server bug #39615). + Distinguish 401 (re-auth) from 403 (forbidden, do not re-auth) from 429/503 + (back off, honour `Retry-After`). Nextcloud returns 401 with + `PasswordLoginForbidden` when 2FA is on and a real password was used — worth + detecting for a precise message. +- **On account removal: call `DELETE /ocs/v2.php/core/apppassword`** + (this one *does* need `OCS-APIRequest: true`), best-effort. Otherwise + uninstalling never revokes access, and orphaned entries accumulate that the user + cannot identify — see the User-Agent point above. + +--- + +## The sync engine + +- Collection discovery and refresh; per-collection sync state (in a `TaskLists` + `SYNC*` slot, per the squat table). +- ⚠️ **Baseline is `REPORT calendar-query` with a VTODO comp-filter and *no* + time-range**, matching DAVx5 — which deliberately does not use RFC 6578 for + tasks, and omits the time-range *"because some servers don't return tasks + without time at all"*. `sync-collection` is the **optimisation on top**, not + the primary path. The draft had this the wrong way round. +- CTag / sync-token loop (RFC 6578 `sync-collection`) where it works. ⚠️ **The + full-reconciliation path is not a fallback for weak servers — it is a permanent + safety net on every server**, because a pruned change log behind a still-valid + token is undetectable (below). +- Local change detection: `_dirty = 1 OR _deleted = 1`, scoped by `list_id`. +- `If-Match` conditional PUT. +- Backoff and partial-failure recovery. A failed collection must not fail the + account. + +### ⚠️ RFC 6578, and where every shipping client has bugs + +The audit read RFC 6578 in full (no errata, fourteen years on) plus the +w3c-dist-auth threads that are its only authoritative gloss. Calibration first: +**Evolution shipped `sync-collection` in June 2026** against a request open since +2019; **vdirsyncer has declined it for twelve years**; **Thunderbird still has +unlanded patches** for one of the cases below. The library covers about a third. + +**⚠️ Note DAVx5 does not use RFC 6578 for tasks at all** — its own documentation +says *CalDAV tasks: use `REPORT calendar-query`*, because a collection +advertising both VEVENT and VTODO would stream every event change and force a +fetch to discover it isn't a task. That is a real argument for making +`calendar-query` our primary path and `sync-collection` the optimisation. + +1. **Invalidation has no status code.** §3.2 defines the `DAV:valid-sync-token` + precondition and never assigns an HTTP status. Observed: **403** (sabre ⇒ + Nextcloud, ownCloud, Baïkal, and Radicale 3.1.8), **400** (Google, CalDAV and + CardDAV), **409** (Radicale, per its maintainer), **412** (accepted by + Evolution). One server family, two codes across versions. + **Rule: ignore the status; match `` anywhere in the body + on any 4xx.** Thunderbird's CardDAV code accepts only 400 and therefore never + recovers from the 403 that most of the self-hosted world emits. +2. **Initial sync must not report deletions** (§3.4), so a forced full resync + cannot learn what was deleted. **Mark-and-sweep is mandatory** — and the + `initialIncomplete` flag must be persisted *alongside* the token, or a resumed + partial sync sweeps against an incomplete "present remotely" set and **deletes + live data**. +3. **⚠️ Persist the token only after the bodies are applied.** The RFC's own + Appendix B gets this backwards — it associates the new token with the + collection *first*, then fetches. Death in between loses those changes + permanently. Under WorkManager, process death mid-sync is routine, not + exotic. Persist per page, after step 5, atomically with `initialIncomplete`. +4. **Worse than invalidation: a token the server still accepts over a change log + it already pruned.** Returns 207, zero changes, "you're current" — no error, + no recovery, and **RFC 6578 provides no signal for it.** Nextcloud's + `totalNumberOfSyncTokensToKeep` defaults to 10,000 and its own admin manual + warns this "will lead to premature data deletion and synchronization + problems"; Baïkal #1140 has shipped an empty change set *forever* since 2022. + **The only mitigation is periodic full reconciliation** (PROPFIND `Depth: 1` + + ETag diff) on a slow cadence regardless of the token. +5. **Truncation: detect the 507 on the SELF href, not the error element.** + §3.6's `DAV:number-of-matches-within-limits` is a SHOULD and sabre omits it + entirely. Distinguish it from a **507 as the outer HTTP status**, which means + your `DAV:limit` could not be honoured — retry without the limit, don't page. + iCloud emits a SELF response with status **200**; ignore that one. + **Do not send `DAV:limit`** — Nextcloud regressed it to a localised HTML error + page in 28.0.10/29.0.7/30.0.0. Cap by bytes client-side instead, and still + implement 507 handling. Add an iteration cap **and** a no-progress guard: the + RFC never requires the token to advance, and an unchanged token spins forever. +6. **`supported-report-set` is a hint, not a contract.** Radicale advertised + `sync-collection` for years without implementing it; Cyrus 3.8 advertises it + and rejects the mandated empty token. A 207 with no `` must + **degrade to PROPFIND, not throw**. +7. **Tokens are opaque.** §3.2 says they MUST be URIs; Google, iCloud, fruux + (`0`), and grommunio all violate it. Never parse or validate. And **never + reuse a token across collections** — sabre validates only the prefix, then + returns a wrong-but-plausible delta with no error. Key by + `(accountId, collectionUrl)`. +8. **Deletion is `404` at *``* level.** A 404 + inside a `` is a missing *property* on a resource that exists. + Confusing the two nesting levels deletes live data. +9. **Three membership edge cases** (§3.5): create-then-delete between syncs is + reported as removed, so the delete handler must no-op on an href it has never + seen; delete-then-recreate at the same URI is reported as **changed**, so href + identity is not UID identity — re-read the UID from the body; and **ACL churn + may be reported as removal**, so toggling a share can look like mass deletion. + Apply a sanity threshold before acting on a large delete batch. +10. **`sync-collection` never reports collection property changes.** §3.5.1 keys + "changed" on an entity tag, and a calendar collection has no entity body. So + displayname, colour and **read-only status can only be refreshed by PROPFIND + on the home set** — and on sabre, `CalendarHome` does not implement + `ISyncCollection` at all, so there is no sync-collection there to use. + DAVx5 has this exact gap open as its own bug. +11. **`calendar-data` inside `sync-collection` is sanctioned by neither RFC.** + RFC 4791 §9.6 says it "is not a WebDAV property"; it works on sabre only + because that codebase exposes it as one by explicit accident. Request + `getetag` + `resourcetype`, then batch `calendar-multiget` — **and match the + returned hrefs against what you asked for**, because real servers reply with + responses for unrelated URLs. +12. **`getctag` is formally deprecated** by `caldav-ctag-03` in favour of this + REPORT — and every shipping client still keeps it as a fallback. Do the same, + but never compare a ctag to a sync-token. + +**What `dav4jvm` actually gives us:** spec-correct serialisation, `Depth: 0`, +`"infinite"` spelled right, a streaming `Flow` with the token arriving as an +`ExtraProperty`, and typed exceptions. **What it does not:** the truncation loop, +507 detection, any `valid-sync-token` handling (its `Error.kt` says outright +*"there is no logic for subclassing errors"*), mark-and-sweep, `initialIncomplete` +persistence, or multiget orchestration. And its error extraction only parses XML +content-types within a 20 KB excerpt at depth 1 — so a `` served as +`text/html`, or buried behind a PHP stack trace (exactly what ownCloud and Baïkal +emit), yields no recovery. Add a raw-body substring fallback, as Evolution does. + +### ⚠️ Scheduling has a ceiling the draft didn't price + +"WorkManager with network constraints" was the entire treatment. Reality: + +- An ordinary worker is documented for **< 10 minutes**. An initial full sync of a + large collection over a slow homelab link will exceed it. DAVx5's own + `workerWaitTimeout` is 10 minutes. +- Escalating to `setForeground` pulls in `FOREGROUND_SERVICE` + + `FOREGROUND_SERVICE_DATA_SYNC` (missing ⇒ `SecurityException` at targetSdk 34+), + a `tools:node="merge"` override on WorkManager's own service, and the + **Android 15 six-hours-per-24 `dataSync` budget** whose failure mode is a fatal + `RemoteServiceException`. Android 15 also **forbids starting a `dataSync` FGS + from `BOOT_COMPLETED`** — and we have a boot receiver. +- **Android 16 removed the shield**: jobs running alongside a foreground service + now obey the job runtime quota, and the `active` bucket is capped at 20 min / + rolling 60 min. + +**Therefore:** make sync **chunked and resumable** — persist the sync-token/ETag +cursor per collection so a killed worker resumes rather than restarts. Hard socket +and wall-clock timeouts. Periodic sync is a plain `PeriodicWorkRequest`, no FGS. +"Sync now" from a visible screen uses `setExpedited(RUN_AS_NON_EXPEDITED_WORK_REQUEST)` +— and implement `getForegroundInfo` unconditionally, since omitting it crashes +below API 31 and we support 29. FGS only for user-initiated full syncs, with +`Service.onTimeout → stopSelf()` as a backstop. Play requires a video demo per +declared FGS type. + +⚠️ **Be honest about cadence.** `PeriodicWorkRequest`'s 15-minute floor is +nominal. In the `rare` and `restricted` buckets network access is disabled +outright; Doze allows idle apps network roughly **once a day**. Combined with +unmetered-only, worst case is genuinely "once overnight". Promise eventual +consistency, and sync hard on app open and on connectivity-regained. + +### ⚠️ Server reality — the matrix, audited + +The draft listed six servers as a test matrix. What they actually do: + +| Server | VTODO | The thing that will bite | +|---|---|---| +| **Nextcloud** (sabre) | ✅ | ⚠️ **Never round-trip a body fetched from a shared calendar** — see below. Never send ``. Per-calendar UID uniqueness ⇒ **409 `no-uid-conflict`**. Trashbin renames the href to `-deleted.ics` ⇒ 403 on delete-then-recreate. MKCALENDAR is rate-limited 10/hour ⇒ 429, max 30 calendars ⇒ 403 | +| **Baïkal** (sabre) | ✅ | Handles `` and 507 **correctly** — the reference implementation for that path. Never prunes its change log, so tokens stay valid forever. Defaults to **Digest** auth | +| **Radicale** | ✅ | `supported-calendar-component-set` is **never enforced** — a VEVENT PUT into a VTODO-only collection is accepted. Advertises three reports it does not implement. Its VTODO time-range now implements all eight RFC 4791 §9.9 rows — **the widespread "Radicale doesn't do time ranges" claim is stale** | +| **SOGo** | ✅ | ⚠️ **Never invalidates a sync token** (`valid |= …` makes the check always pass) and tokens are **second-granularity**, so you re-receive up to a second of changes every sync. ⚠️ **The ETag is a row-version counter, and the body is regenerated per-principal** — same ETag, different bytes. Cannot create a VTODO-only collection at all | +| **Fastmail** | ✅ | ⚠️ **Reframe as supported.** The backend does VTODO fine; their own UI hides task-only calendars by design. Create collections as **mixed `VEVENT,VTODO`** so the list doesn't vanish from Fastmail's UI. Requires an app password; Basic plan has no CalDAV | +| **iCloud** | ⚠️ | **Reminders left CalDAV at iOS 13.** A new VTODO collection syncs bidirectionally but is **invisible in Reminders.app forever**. Market it as "store tasks in iCloud", never as "sync with Apple Reminders". Cheap detection: no VTODO-capable collection in the home set ⇒ upgraded account | +| **Google** | ❌ | ⚠️ **Drop it.** First-party docs: *"Doesn't support VTODO or VJOURNAL data"* and no MKCALENDAR. Refuse with an explanation, don't fail with a 401 | + +**Two data-destruction landmines, both confirmed from server source:** + +1. **Nextcloud rewrites task bodies on GET from a shared calendar.** + `CalendarObject::get()` strips `VALARM` on read-only shares, and for + `CLASS:CONFIDENTIAL` reduces the object to a VEVENT-shaped whitelist that + **deletes `DUE`, `STATUS`, `COMPLETED`, `PERCENT-COMPLETE`, `PRIORITY` and + `RELATED-TO`** — every property that makes it a task. **The ETag is left + untouched**, so `ETag ≠ md5(body)` and re-PUTting what you downloaded destroys + the task. Baïkal never does this. +2. **sabre runs vobject `REPAIR` on every PUT** unless you send + `Prefer: handling=strict` — adding UID/DTSTAMP/PRODID/VERSION — and when it + modifies the object it **suppresses the ETag response header**, so you must + re-GET rather than assume. It also 415s on **`DUE` < `DTSTART`**, value-type + mismatch between them, multiple UIDs, mixed component types in one resource, + and a present `METHOD`. + +**Three consequences for the design:** + +- **`supported-calendar-component-set` is not an invariant.** Unenforced on + Radicale, discarded by SOGo, immutable on sabre (403 if you try to change it). + Filter on it, but never rely on it. +- **VTODO scheduling exists nowhere.** sabre's own docs: *"We don't do VTODO + scheduling yet, and only support VEVENT."* Treat `ORGANIZER`/`ATTENDEE` on a + task as inert text to round-trip — which is a mercy, since it also means + scheduling never rewrites our objects or suppresses our ETags. +- **Never trust an ETag as a content hash** (SOGo, and Nextcloud shares). + +### ⚠️ Writing: conditional PUT, and the conflict policy that had to change + +**`If-None-Match: *` on create. `If-Match` on update and DELETE.** The draft said +"`If-Match` on every PUT", which omits the creation case entirely — RFC 4791 +§5.3.2 asks for `If-None-Match: *` there, and all three reference clients send it. +Without it, a filename collision (two devices minting the same UID, or a sanitiser +folding two UIDs onto one name) makes the second PUT **silently destroy the +first**, with no ETag to protect it because we have never seen the resource. + +⚠️ **412 means three different things** and the draft's single rule conflated them: + +| On | Means | Do | +|---|---|---| +| create (`If-None-Match`) | the filename is taken | re-fetch that href; adopt if the UID matches, else regenerate the name as a UUID | +| update (`If-Match`) | the server has a newer version | conflict resolution, below | +| update, resource gone | no selected representation, so the condition is false — **spec-correct** (Radicale and DAViCal do this) | `HEAD` to disambiguate; 404 ⇒ delete-vs-edit, not a conflict | + +⚠️ **The proposed conflict policy is unimplementable and is withdrawn.** The draft +said the local version would be *"preserved rather than discarded — a duplicate +task, marked, in the same list."* RFC 4791 §4.1 requires a **UID to be unique +within a collection**, and every target server enforces it: Nextcloud, Radicale +and SOGo all answer **409 `CALDAV:no-uid-conflict`**. So the preserved duplicate +can never be uploaded — same UID fails forever, a new UID forks a task that never +reconciles. The "visible clutter" the design wanted is either a permanently +failing row or a permanent fork. + +**Pick one and write the consequence down** (open question 2, now with real +options): **server-wins and discard the local edit** — DAVx5's stated policy — +or **server-wins and fork under a new UID**, marked in the UI, with the new UID +persisted so the fork is first-class from that moment. Prompting is unavailable; +a background sync has nobody to ask. + +⚠️ **The ETag may be weak or absent, and then `If-Match` can never succeed.** +RFC 4791 §5.3.4: when the server does not store your bytes verbatim, *"a strong +entity tag MUST NOT be returned"*. RFC 9110 §13.1.1: *"A weak entity-tag cannot be +used with If-Match."* On sabre this is the **default path** — `validateICalendar` +runs vobject `REPAIR` unless you send `Prefer: handling=strict`, and +`Server::createFile` then deliberately withholds the ETag. Worse, **weak ETags +also arrive from the user's reverse proxy**: any gzip-compressing nginx, +Cloudflare or Traefik in front of Nextcloud produces them, so the risk tracks the +user's deployment rather than their server software. + +Therefore: send **`Prefer: handling=strict`** to sabre-based servers — the +cheapest single fix in this whole audit, since it preserves both our bytes and +the ETag. Request `Accept-Encoding: identity`. Strip `W/` and keep a weak flag. +**If a PUT returns no ETag or a weak one, discard it and re-fetch** for the strong +validator *and* the server's canonical body. Bound every 412 retry loop. + +⚠️ **Errors are not one status.** RFC 4791 §5.3.2.1 defines **eleven** +preconditions, and the one we will hit most is not in the draft at all: **sabre +returns 415** for a VTODO whose `DUE` precedes `DTSTART`, whose `DUE`/`DTSTART` +value types disagree, or which carries a `METHOD`. The first two are reachable +from ordinary UI actions and must be validated client-side. Also: **507 MUST NOT +be auto-retried** (RFC 4918 §11.5 — quota exhaustion is common on hosted +Nextcloud, and a generic backoff loop violates the spec), and **5xx is not safely +retryable either** — a contradictory `RRULE`/`EXDATE` pair returns 500 from +Nextcloud and will do so forever. + +So line-for-line with "a failed collection must not fail the account", add its +twin: ⚠️ **a failed resource must not fail the collection.** Per-resource +quarantine with a failure counter, not backoff. A single HTTP 400 has halted all +calendar sync in DAVx5 for weeks. + +**DELETE needs the same care:** conditional on `If-Match`; **404/410 count as +success**; a resource deleted locally that was never uploaded is never DELETEd. +And Nextcloud's trashbin renames the href to `-deleted.ics`, so +delete → recreate → delete the same href returns **403** — which task apps hit +constantly, because they reuse hrefs. + +**href and UID are unrelated.** RFC 4791 §5.3.2 opens by saying the URL *"is +entirely arbitrary and does not need to bear a specific relationship"* to the +content, and `.ics` is MAY. Sanitise filenames following vdirsyncer's rule — +`a–zA–Z0–9_.-+`, **excluding `@`**, because some servers percent-encode it in the +path and then reject or "repair" the URL, and RFC 4791's own example UID is +`…@example.com`. Cap the basename around 200 bytes; fall back to a UUID. + +### Manifest and permissions for phase 3 + +⚠️ The draft named only `INTERNET`. Actually needed: `INTERNET`, +`READ_SYNC_SETTINGS`, `WRITE_SYNC_SETTINGS`, `ACCESS_NETWORK_STATE` (merged in by +`work-runtime`, but it shows in F-Droid's permission diff), plus +`FOREGROUND_SERVICE` + `FOREGROUND_SERVICE_DATA_SYNC` if the FGS route is taken. +The authenticator `` must be `android:exported="true"` guarded by +`android:permission="android.permission.ACCOUNT_MANAGER"` — note +`android.permission.ACCOUNT_AUTHENTICATOR` **does not exist**. + +Also missing from `libs.versions.toml` entirely: `androidx.work`, +`androidx.hilt:hilt-work`, `androidx.browser`. With Hilt that means a +`HiltWorkerFactory`, removing the default `WorkManagerInitializer`, and an +`@EarlyEntryPoint` for the authenticator service. + +### Two network facts for homelab users + +- ⚠️ **Ship a `network-security-config` with ``.** Since + Android 7, a user who correctly installs their private CA into Android's store + is *still* not trusted by apps. Cleartext `http://` is blocked by default since + API 28; any escape hatch must be a narrow, warned, per-account opt-in — Play's + User Data policy requires modern cryptography in transit. +- ⚠️ **Android 17 / targetSdk 37 breaks LAN CalDAV.** Local network protections + become mandatory: TCP to a local address and `.local` resolution require the + runtime `ACCESS_LOCAL_NETWORK` permission. The failure mode is a **connection + timeout, not a `SecurityException`** — "my Nextcloud at 192.168.1.50 just + hangs", the worst bug-report shape there is. We are safe at targetSdk 36 (which + gets an implicit grant) and must **not** request it before targeting 37 — but + `compileSdk` is already 37 and Play's floor rises annually, so this is a + scheduled break aimed precisely at the self-hosting demographic. + +--- + +## External mode's future + +`STORAGE-AND-SYNC.md` keeps Posture A as a user choice; a later draft proposed +replacing it with a one-time importer. Premature — it argues against something +shipped and working — but it is the right question one phase early. + +Once we sync ourselves, External mode's only job is reading tasks in someone +else's app. Costs are real and permanent: capability divergence (tasks.org's fork +is DB 22 and lacks `is_recurring`, which is why `TaskMapper.task` derives +recurrence from `rrule`/`rdate`), per-backend UI degradation forever, two +dangerous permissions in a static manifest, a doubled device matrix. + +**Decide before phase 1** — it determines whether the mapper and UI stay +dual-capable. Open question 3. + +If retired, the importer spec is sound: read-only, one-time, idempotent; identify +local lists by `ACCOUNT_TYPE`, treating unknown types as synced; **import the +`Properties` table, not just `Tasks`** (categories, alarms, `RELATED-TO`, `X-` +props — the commonly forgotten half); preserve `_UID`s; persist +`(authority, _ID, uid)` for idempotency; and while scanning, read the *names* of +synced lists so the UI can say *"these 3 lists come from cloud.example.de — add +that account to bring them back"*. + +--- + +## Play compliance + +⚠️ Absent from the first draft entirely. + +- **A privacy policy is mandatory regardless of collection**, linked both in Play + Console and **inside the app**. +- **"Not collected" is not defensible.** Play defines collection as transmitting + data off the device *irrespective of recipient*. Neither the on-device nor the + ephemeral exemption applies, and the E2EE exemption doesn't survive TLS to a + server that reads plaintext. File **Collected, not Shared**, encrypted in + transit. There is no credentials category, but "authentication information" is + explicitly named as personal and sensitive data. +- **Account Deletion policy does not apply** (offline-created accounts are out of + scope), but ship a "Remove account and delete local data" action anyway — + cheap insurance against a reviewer pattern-matching. +- **Do not ship `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` in the Play build.** + Generic server sync is not on the acceptable-use list. Use + `ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS`. DAVx5 declares the former and is + F-Droid-safe on it; we would not be. + +--- + +## What lands in floret-kit + +| Candidate | Kit module | Note | +|---|---|---| +| DAV client + iCalendar parse/serialise | new, e.g. `core-dav` | **the big one.** Calendula needs the same primitives. Design for two consumers from the start | +| Sync-adapter + authenticator scaffolding | new, e.g. `core-sync` | including the stub-adapter→WorkManager bridge, which is pure mechanics | +| Nextcloud Login Flow v2 | with `core-dav` | pure protocol, zero task knowledge | +| Credential storage | kit | Keystore mechanics, not domain | +| Third-party licence screen | kit | Calendula needs it the moment it takes any of the above | + +**Stays app-local:** the VTODO ↔ `TaskContract` mapper (domain, and where all the +judgement calls live), `ICalendarWriter`, conflict policy and its UI, and +everything about storage modes. + +--- + +## Test strategy + +- **Round-trip corpus first** — VTODO fixtures from each target server; assert + `parse → store → serialise` is byte-stable for untouched properties. The + mapper's specification, not its regression net. +- **Server matrix:** Nextcloud, Radicale, Baïkal (**on Digest**), SOGo, Fastmail, + iCloud — with the per-server traps above as named test cases. Google is out. + Two that must be explicit tests, because both silently destroy data: + **round-tripping a task from a shared Nextcloud calendar**, and **an + ETag-unchanged body change on SOGo**. +- **Conflict scenarios:** concurrent edit, delete-vs-edit, collection removed + server-side, credentials revoked mid-sync. +- **Interop:** edit the same task from Nextcloud web and from tasks.org + DAVx5. +- **Device, not Robolectric,** for account paths — `ProviderAccountCleanupTest` + skips on ARM64. Two specific device cases the audit added: **restore a cloud + backup onto a fresh device** and confirm nothing is pruned; **remove the + account** and confirm only our lists go. +- ⚠️ **Minified-build tests.** `:app` runs `isMinifyEnabled` + `isShrinkResources` + in release, and `proguard-rules.pro` already documents two R8-pruning outages. + ical4j resolves through seven `META-INF/services` files and instantiates its + cache from a class-name string — it needs `-keep class net.fortuna.ical4j.** { *; }` + plus ~8 `-dontwarn` lines, and is effectively unshrinkable. Without them this + fails in `release` only. + +--- + +## Open questions + +1. **dav4jvm + cert4android distribution *and* Java target.** Scoped JitPack, + vendor, or in-house verbs? Now compounded: 4.x requires **Java 21** and we + target 17 across `:app` and all of floret-kit. Vendoring recompiles at our own + target and freezes the API churn — it looks better than it did. Before phase 2. +2. **Conflict policy** — preserve-local-on-412, or documented LWW? +3. **External mode** — survives, or becomes an importer? Before phase 1. +4. ~~**Canonical recurring-completion behaviour**~~ **closed:** the store writes + model (a), `RECURRENCE-ID` overrides sharing the master's UID. Read all four. +5. **The DAVx5 enum ask** — worth filing, and what compatibility we owe if it + lands. ⚠️ Reshaped: it now means "sync into an app that publishes no provider". +6. ~~**Does Local→Synced migrate, or do synced lists start empty?**~~ **closed:** + neither — attaching an account to a list is an `UPDATE`, so there is nothing + to migrate. +7. ~~**lib-recur — pin at 0.12.2, or rewrite the provider's iterators?**~~ + **closed** with the provider's deletion; `:app` owns the version. + +Answered elsewhere and **not** open: the account model (`AccountManager` **plus a +stub sync adapter**), `ical4android` (superseded by `synctools`, GPLv3), and the +storage question — which was reopened once, answered the other way, and is now +shipped. + +--- + +## Dead ends — do not revisit + +- **Depending on DAVx5 for sync.** Settled in `STORAGE-AND-SYNC.md`. +- **`synctools` / `ical4android`.** GPLv3. The temptation recurs because it does + exactly the right mapping against exactly our schema. +- ~~**Rewriting storage to Room before sync exists.**~~ ⚠️ **This one was + revisited, and it was right to.** The phase-1 audit measured the provider's + sync bookkeeping — the reason it was kept — and found most of it broken, absent + or unusable (the table above). Reasoning in + [`STORAGE-DECISION.md`](STORAGE-DECISION.md). Kept here as a reminder that a + dead end is only dead against the evidence that closed it. +- ⚠️ **AccountManager + WorkManager with no registered sync adapter.** Not a + design choice — a silent no-op at targetSdk ≥ 34. +- ⚠️ **Writing through the `instances` URI as a sync adapter.** The flag is + ignored there; every such write dirties the row and forks an override. +- ⚠️ **Hand-rolled TrustManager to avoid a GPL licence cert4android does not + have.** + +--- + +## Related + +- [`STORAGE-AND-SYNC.md`](STORAGE-AND-SYNC.md) — where task data lives. This is + its step 5. +- [`../provider/PROVENANCE.md`](../provider/PROVENANCE.md) — every deviation from + upstream dmfs. Change 1 is load-bearing for the account model here. +- [`ARCHITECTURE.md`](ARCHITECTURE.md) §4 — the data seam the adapter writes + underneath. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b691c2a..c57fbae 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,11 @@ composeBom = "2026.05.01" # Re-evaluate when 1.5.0 stable lands. material3 = "1.5.0-alpha21" datastore = "1.2.1" +# Room — Agendula's own task store (docs/OWN-STORE.md). +room = "2.8.4" +kotlinxSerialization = "1.8.1" +# SAF directory writing for export/backup (DocumentFile). +documentfile = "1.1.0" junit = "6.1.0" junitPlatform = "6.1.0" truth = "1.4.5" @@ -28,6 +33,22 @@ androidxTestRules = "1.7.0" # Glance: 1.1.1 is the latest stable (1.2.0 is still rc, 1.3.0 alpha). glance = "1.1.1" +# --- :provider (vendored dmfs task provider) --------------------------------- +# Versions the upstream 1.4.2 source was written against. These are its runtime +# dependencies, not ours — nothing above the data layer touches them, and they +# only move when we deliberately resync the fork. See provider/PROVENANCE.md. +dmfsJems = "1.43" +dmfsRfc5545Datetime = "0.2.4" +dmfsLibRecur = "0.12.2" +# The provider's own test suite is JUnit 4 + Robolectric, unlike the app's +# JUnit 5. Kept as upstream wrote it (rewriting ~13 test classes would forfeit +# the regression coverage that makes vendoring safe), but on current versions: +# upstream pins Robolectric 3.5.1, which predates AGP's resource handling. +robolectric = "4.16" +junit4 = "4.13.2" +hamcrest = "3.0" +mockito = "5.20.0" + [libraries] # AndroidX core androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -53,9 +74,19 @@ androidx-compose-material-icons-extended = { group = "androidx.compose.material" hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } +# Room — the own-store database +androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } +androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } +androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" } +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } + # DataStore androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } +# SAF — writing the export into a user-chosen folder +androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "documentfile" } + # Unit tests junit-jupiter-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junit" } junit-jupiter-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "junit" } @@ -84,6 +115,8 @@ androidx-navigation-compose = { group = "androidx.navigation", name = "navigatio # Lifecycle compose (for collectAsStateWithLifecycle) androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleCompose" } +# ProcessLifecycleOwner — the WAL checkpoint hangs off ON_STOP. +androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycleRuntime" } # Glance — Jetpack home-screen widgets (Compose-like RemoteViews) androidx-glance-appwidget = { group = "androidx.glance", name = "glance-appwidget", version.ref = "glance" } @@ -92,8 +125,19 @@ androidx-glance-material3 = { group = "androidx.glance", name = "glance-material # Android tests - GrantPermissionRule androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTestRules" } +# :provider — vendored dmfs task provider (see provider/PROVENANCE.md) +dmfs-jems = { group = "org.dmfs", name = "jems", version.ref = "dmfsJems" } +dmfs-jems-testing = { group = "org.dmfs", name = "jems-testing", version.ref = "dmfsJems" } +dmfs-rfc5545-datetime = { group = "org.dmfs", name = "rfc5545-datetime", version.ref = "dmfsRfc5545Datetime" } +dmfs-lib-recur = { group = "org.dmfs", name = "lib-recur", version.ref = "dmfsLibRecur" } +robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } +junit4 = { group = "junit", name = "junit", version.ref = "junit4" } +hamcrest = { group = "org.hamcrest", name = "hamcrest", version.ref = "hamcrest" } +mockito-core = { group = "org.mockito", name = "mockito-core", version.ref = "mockito" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } diff --git a/scripts/make_import_fixture.py b/scripts/make_import_fixture.py new file mode 100644 index 0000000..4fee099 --- /dev/null +++ b/scripts/make_import_fixture.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""Build the dmfs `tasks.db` fixture the one-shot import is tested against. + +The provider is being deleted (docs/OWN-STORE.md phase 5), so the import cannot +keep a real v0.3.x database around by asking the provider to create one. This +writes the schema the provider's TaskDatabaseHelper produces at DATABASE_VERSION +23 — tables `Lists`, `Tasks`, `Properties` only, which are the three the import +reads — and seeds a spread that covers what the import has to get right. + +Regenerate with: python3 scripts/make_import_fixture.py +""" + +from __future__ import annotations + +import os +import sqlite3 +import sys + +OUT = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "app/src/androidTest/assets/tasks-v23.db", +) + +DDL = [ + """CREATE TABLE Lists ( + _id INTEGER PRIMARY KEY AUTOINCREMENT, + account_name TEXT, account_type TEXT, list_name TEXT, list_color INTEGER, + list_access_level INTEGER, visible INTEGER, sync_enabled INTEGER, + list_owner TEXT, _dirty INTEGER DEFAULT 0, _sync_id TEXT, + sync_version TEXT, sync1 TEXT, sync2 TEXT, sync3 TEXT, sync4 TEXT, + sync5 TEXT, sync6 TEXT, sync7 TEXT, sync8 TEXT)""", + """CREATE TABLE Tasks ( + _id INTEGER PRIMARY KEY AUTOINCREMENT, version INTEGER DEFAULT 0, + list_id INTEGER NOT NULL, title TEXT, location TEXT, geo TEXT, + description TEXT, url TEXT, organizer TEXT, priority INTEGER, + task_color INTEGER, class INTEGER, completed INTEGER, + completed_is_allday INTEGER, percent_complete INTEGER, + status INTEGER DEFAULT 0, is_new INTEGER, is_closed INTEGER, + dtstart INTEGER, created INTEGER, last_modified INTEGER, + is_allday INTEGER, tz TEXT, due INTEGER, duration TEXT, rdate TEXT, + exdate TEXT, rrule TEXT, parent_id INTEGER, sorting TEXT, + has_alarms INTEGER, has_properties INTEGER, pinned INTEGER, + original_instance_sync_id TEXT, original_instance_id INTEGER, + original_instance_time INTEGER, original_instance_allday INTEGER, + _dirty INTEGER DEFAULT 1, _deleted INTEGER DEFAULT 0, _sync_id TEXT, + _uid TEXT, sync_version TEXT, sync1 TEXT, sync2 TEXT, sync3 TEXT, + sync4 TEXT, sync5 TEXT, sync6 TEXT, sync7 TEXT, sync8 TEXT)""", + """CREATE TABLE Properties ( + property_id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER, + mimetype INTEGER, prop_version INTEGER, + data0 TEXT, data1 TEXT, data2 TEXT, data3 TEXT, data4 TEXT, data5 TEXT, + data6 TEXT, data7 TEXT, data8 TEXT, data9 TEXT, data10 TEXT, + data11 TEXT, data12 TEXT, data13 TEXT, data14 TEXT, data15 TEXT, + prop_sync1 TEXT, prop_sync2 TEXT, prop_sync3 TEXT, prop_sync4 TEXT, + prop_sync5 TEXT, prop_sync6 TEXT, prop_sync7 TEXT, prop_sync8 TEXT)""", +] + +LOCAL = ("Local", "org.dmfs.account.LOCAL") +CALDAV = ("me@example.org", "bitfire.at.davdroid") +ALARM_MIMETYPE = "vnd.android.cursor.item/alarm" + +# 2026-01-15T09:00:00Z, and a day in millis. +T0 = 1_768_467_600_000 +DAY = 86_400_000 + + +def seed(db: sqlite3.Connection) -> None: + lists = db.executemany( + "INSERT INTO Lists (_id, account_name, account_type, list_name, list_color," + " visible, sync_enabled, list_owner) VALUES (?,?,?,?,?,?,?,?)", + [ + (1, *LOCAL, "Personal", 0xFF7A5C6B, 1, 1, None), + (2, *LOCAL, "Hidden list", 0xFF445566, 0, 1, None), + # An external account inside our own authority: only reachable if the + # user pointed DAVx5 at us. Imported as a local list, UID preserved. + (3, *CALDAV, "Work", 0xFF2244AA, 1, 1, "Me"), + ], + ) + del lists + + def task(**kw): + cols = ", ".join(kw) + marks = ", ".join("?" * len(kw)) + db.execute(f"INSERT INTO Tasks ({cols}) VALUES ({marks})", tuple(kw.values())) + + task(_id=1, list_id=1, title="Buy milk", due=T0 + DAY, status=0, + _uid="a1b2c3d4-0000-4000-8000-000000000001", created=T0, last_modified=T0) + # No UID: the import mints one. + task(_id=2, list_id=1, title="Call the dentist", due=T0 + 2 * DAY, status=1, + percent_complete=40, created=T0, last_modified=T0) + task(_id=3, list_id=1, title="Gather receipts", parent_id=1, status=0, + _uid="a1b2c3d4-0000-4000-8000-000000000003", created=T0, last_modified=T0) + task(_id=4, list_id=1, title="Renew domain", status=2, percent_complete=100, + completed=T0 - DAY, is_closed=1, + _uid="a1b2c3d4-0000-4000-8000-000000000004", created=T0, last_modified=T0) + task(_id=5, list_id=1, title="Water the plants", dtstart=T0, due=T0 + 3600_000, + rrule="FREQ=WEEKLY;BYDAY=MO,TH", tz="Europe/Berlin", + _uid="a1b2c3d4-0000-4000-8000-000000000005", created=T0, last_modified=T0) + task(_id=6, list_id=1, title="Team offsite", dtstart=T0 - T0 % DAY, + due=T0 - T0 % DAY + DAY, is_allday=1, + _uid="a1b2c3d4-0000-4000-8000-000000000006", created=T0, last_modified=T0) + # Deleted-but-unsynced: gone as far as the user is concerned, so not imported. + task(_id=7, list_id=1, title="Cancelled thing", _deleted=1, + _uid="a1b2c3d4-0000-4000-8000-000000000007", created=T0, last_modified=T0) + task(_id=8, list_id=2, title="Task in a hidden list", status=0, + _uid="a1b2c3d4-0000-4000-8000-000000000008", created=T0, last_modified=T0) + task(_id=9, list_id=3, title="Ship the release", due=T0 + 5 * DAY, status=0, + _uid="a1b2c3d4-0000-4000-8000-000000000009", created=T0, last_modified=T0, + _sync_id="https://dav.example.org/tasks/9.ics") + + db.executemany( + "INSERT INTO Properties (property_id, task_id, mimetype, data0, data1, data2, data3)" + " VALUES (?,?,?,?,?,?,?)", + [ + # data0 minutes before, data1 reference (1 = DUE), data3 alarm type. + (1, 1, ALARM_MIMETYPE, "30", "1", None, "1"), + (2, 9, ALARM_MIMETYPE, "1440", "1", "Ship it", "1"), + # A non-alarm property the import must skip. + (3, 1, "vnd.android.cursor.item/category", "Errands", None, None, None), + ], + ) + + +def main() -> int: + os.makedirs(os.path.dirname(OUT), exist_ok=True) + if os.path.exists(OUT): + os.remove(OUT) + db = sqlite3.connect(OUT) + try: + for statement in DDL: + db.execute(statement) + db.execute("PRAGMA user_version = 23") + seed(db) + db.commit() + finally: + db.close() + print(f"wrote {OUT}") + return 0 + + +if __name__ == "__main__": + sys.exit(main())