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.
@@ -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())