Compare commits
24 Commits
release/v0
...
release/v1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f735ffce7 | ||
| fdbc236ab4 | |||
| ec2e2eb59d | |||
| 9ff6027e50 | |||
| fcee1d1736 | |||
|
|
d7131087cb | ||
| faee90f8b1 | |||
| 90140112bb | |||
| 1d4fe5b301 | |||
| 5abcbfc956 | |||
| 1ed192f150 | |||
| 76f9ae6780 | |||
| 2e915da588 | |||
| 829a27da82 | |||
| fd8363e356 | |||
| 96a2995df4 | |||
| 11b20faf82 | |||
| 8c3cbcf928 | |||
| 13cb27b2ab | |||
| 98ed339346 | |||
| c5041d3f29 | |||
| f978c3727c | |||
| 3d54a896ce | |||
| 47cf99af32 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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
|
||||
|
||||
56
README.md
56
README.md
@@ -3,8 +3,8 @@
|
||||
<h1>Agendula</h1>
|
||||
|
||||
<p><strong>A modern Material 3 Expressive task app for Android.</strong><br>
|
||||
Reads, writes, and reminds — on top of an existing tasks provider, with no own
|
||||
sync stack.</p>
|
||||
Keeps your tasks on your device, or on top of a tasks provider you already use.
|
||||
Open standards, no account required.</p>
|
||||
|
||||
<a href="https://codeberg.org/jlmakiola/agendula/actions"><img src="https://codeberg.org/jlmakiola/agendula/actions/workflows/ci.yaml/badge.svg?branch=main" alt="CI"></a>
|
||||
<img src="https://img.shields.io/badge/Android-10%2B-3DDC84?logo=android&logoColor=white" alt="Android 10+">
|
||||
@@ -15,31 +15,53 @@ sync stack.</p>
|
||||
</div>
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
BIN
app/src/androidTest/assets/tasks-v23.db
Normal file
BIN
app/src/androidTest/assets/tasks-v23.db
Normal file
Binary file not shown.
@@ -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<Preferences>
|
||||
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<String, TaskEntity> =
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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<Long>(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
|
||||
}
|
||||
}
|
||||
@@ -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<String> {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,15 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- Tasks provider access. Both permission sets are declared; the active one
|
||||
<!-- External tasks-provider access, for StorageMode.EXTERNAL only. Both
|
||||
permission sets are declared, since the manifest is static; the active one
|
||||
(org.tasks.* for tasks.org, org.dmfs.* for OpenTasks) is requested at
|
||||
runtime by the permission flow. Both are dangerous-level. -->
|
||||
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. -->
|
||||
<uses-permission android:name="org.dmfs.permission.READ_TASKS" />
|
||||
<uses-permission android:name="org.dmfs.permission.WRITE_TASKS" />
|
||||
<uses-permission android:name="org.tasks.permission.READ_TASKS" />
|
||||
@@ -74,8 +80,11 @@
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- Re-sync reminders when the provider changes (external DAVx5 sync).
|
||||
Targets both known authorities; the host must be static. -->
|
||||
<!-- Re-sync reminders when an external provider changes — DAVx5 pulling
|
||||
tasks while Agendula is backgrounded. External mode only: in OWN mode
|
||||
nothing outside the app can change our data, and Room's
|
||||
InvalidationTracker covers our own writes. An intent-filter host must
|
||||
be a literal, so both external authorities are listed. -->
|
||||
<receiver
|
||||
android:name=".data.reminders.ProviderChangeReceiver"
|
||||
android:exported="true">
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Preferences> 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<Preferences> =
|
||||
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<RoomTasksDataSource>,
|
||||
external: Provider<AndroidTasksDataSource>,
|
||||
): 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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String>)
|
||||
|
||||
/**
|
||||
* 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<ExportDocument>): 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<ExportDocument>): 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"
|
||||
}
|
||||
}
|
||||
@@ -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<Long>? = null): List<ExportDocument> = 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<StorageMode?> = 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<Boolean> = 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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Preferences>,
|
||||
) {
|
||||
suspend fun all(): Map<Long, Long> =
|
||||
suspend fun all(): Set<ScheduledReminder> =
|
||||
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<Long, Long>) {
|
||||
suspend fun replace(scheduled: Set<ScheduledReminder>) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[KEY] = scheduled.entries.map { "${it.key}|${it.value}" }.toSet()
|
||||
prefs[KEY] = scheduled.map { "${it.taskId}|${it.triggerAt}" }.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ExportTask> {
|
||||
// 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<Long, TaskReminder> {
|
||||
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<String, Any?>.toContentValues(): ContentValues {
|
||||
|
||||
@@ -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<RoomTasksDataSource>,
|
||||
private val external: Provider<AndroidTasksDataSource>,
|
||||
) : TasksDataSource {
|
||||
|
||||
private fun active(): TasksDataSource =
|
||||
when (resolver.mode()) {
|
||||
StorageMode.OWN -> room.get()
|
||||
StorageMode.EXTERNAL -> external.get()
|
||||
}
|
||||
|
||||
override fun taskLists(): List<TaskList> = active().taskLists()
|
||||
override fun tasks(query: TaskQuery): List<Task> = active().tasks(query)
|
||||
override fun task(taskId: Long): Task? = active().task(taskId)
|
||||
override fun subtasks(parentTaskId: Long): List<Task> = 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<Long, TaskReminder> = active().alarms()
|
||||
override fun exportTasks(listId: Long): List<ExportTask> = 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 <T> Flow<T>.recoveringFromProviderFailure(fallback: () -> T): Flow<T> =
|
||||
retryWhen { _, attempt ->
|
||||
emit(fallback())
|
||||
delay(retryDelayMs(attempt))
|
||||
true
|
||||
}
|
||||
@@ -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<TaskProvider> = listOf(
|
||||
val EXTERNAL_CANDIDATES: List<TaskProvider> = listOf(
|
||||
TaskProvider(
|
||||
authority = "org.dmfs.tasks",
|
||||
readPermission = "org.dmfs.permission.READ_TASKS",
|
||||
|
||||
@@ -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<Unit>()
|
||||
|
||||
/** 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()
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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<Unit>()
|
||||
|
||||
/** 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()
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<String> = 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.
|
||||
}
|
||||
|
||||
@@ -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<String, Any?> =
|
||||
taskValues(form, tzId) - Tasks.LIST_ID - Tasks.PARENT_ID
|
||||
|
||||
fun completionValues(completed: Boolean, nowMillis: Long): Map<String, Any?> =
|
||||
if (completed) {
|
||||
mapOf(
|
||||
@@ -63,9 +94,26 @@ object TaskWriteMapper {
|
||||
)
|
||||
}
|
||||
|
||||
fun localListValues(name: String, color: Int): Map<String, Any?> = 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<String, Any?> = 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<String, Any?> = mapOf(
|
||||
Lists.NAME to name.trim(),
|
||||
Lists.COLOR to color,
|
||||
)
|
||||
|
||||
fun localListValues(name: String, color: Int): Map<String, Any?> = listValues(name, color) + mapOf(
|
||||
Lists.ACCOUNT_NAME to TasksContract.LOCAL_ACCOUNT_NAME,
|
||||
Lists.ACCOUNT_TYPE to TasksContract.LOCAL_ACCOUNT_TYPE,
|
||||
Lists.VISIBLE to 1,
|
||||
|
||||
@@ -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/<id>` 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()
|
||||
|
||||
@@ -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<Long, TaskReminder>
|
||||
|
||||
/**
|
||||
* 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<de.jeanlucmakiola.agendula.domain.export.ExportTask>
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <T> observing(load: () -> T): Flow<T> = 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<Unit>(Channel.CONFLATED)
|
||||
val handle = dataSource.registerObserver { ticks.trySend(Unit) }
|
||||
ticks.trySend(Unit) // prime the initial emission
|
||||
|
||||
@@ -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<Preferences>,
|
||||
) {
|
||||
|
||||
/** Whether the import has run. Set before the rename, so both guards hold. */
|
||||
val isDone: Flow<Boolean> = 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<ImportCounts> {
|
||||
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<LegacyList>()
|
||||
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<LegacyTaskRow>()
|
||||
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<LegacyAlarm>()
|
||||
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<Long, Long>()
|
||||
val inserted = mutableListOf<Pair<LegacyTaskRow, TaskEntity>>()
|
||||
val seen = mutableSetOf<Triple<Long, String, Instant?>>()
|
||||
|
||||
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<LegacyList>,
|
||||
val tasks: List<LegacyTaskRow>,
|
||||
val alarms: List<LegacyAlarm>,
|
||||
)
|
||||
|
||||
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?,
|
||||
)
|
||||
@@ -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<AccountEntity>
|
||||
|
||||
@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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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?,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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<TaskList> = lists.lists().map(RoomTaskMapper::taskList)
|
||||
|
||||
override fun tasks(query: TaskQuery): List<Task> {
|
||||
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<Task> {
|
||||
val now = clock.now()
|
||||
return tasks.subtasks(parentTaskId)
|
||||
.flatMap { occurrencesOf(it, tasks.overrides(it.task.id), now) }
|
||||
}
|
||||
|
||||
override fun exportTasks(listId: Long): List<ExportTask> =
|
||||
tasks.exportTasks(listId).map(RoomTaskMapper::exportTask)
|
||||
|
||||
override fun alarms(): Map<Long, TaskReminder> =
|
||||
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<TaskEntity>, now: Instant): List<Task> {
|
||||
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<Instant?, Instant?> {
|
||||
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<String>) = onChange()
|
||||
}
|
||||
database.invalidationTracker.addObserver(observer)
|
||||
return AutoCloseable { database.invalidationTracker.removeObserver(observer) }
|
||||
}
|
||||
|
||||
private fun zone(): String = ZoneId.systemDefault().id
|
||||
}
|
||||
@@ -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<TaskAlarmEntity>
|
||||
|
||||
@Query("SELECT * FROM task_alarms WHERE task_id = :taskId")
|
||||
fun forTask(taskId: Long): List<TaskAlarmEntity>
|
||||
|
||||
@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)) }
|
||||
}
|
||||
}
|
||||
@@ -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<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.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<TaskRow>
|
||||
|
||||
@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<TaskEntity>
|
||||
|
||||
@Query("SELECT * FROM tasks WHERE master_id = :masterId AND is_deleted = 0")
|
||||
fun overrides(masterId: Long): List<TaskEntity>
|
||||
|
||||
@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<TaskEntity>
|
||||
|
||||
@Query("SELECT * FROM tasks WHERE is_dirty = 1")
|
||||
fun dirty(): List<TaskEntity>
|
||||
|
||||
// --- 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<TaskListRow>
|
||||
|
||||
@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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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<ExportTask>,
|
||||
)
|
||||
|
||||
/** 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()
|
||||
}
|
||||
@@ -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<Long, String>) {
|
||||
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"
|
||||
}
|
||||
@@ -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<Instant> {
|
||||
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<Instant>()
|
||||
val future = ArrayList<Instant>()
|
||||
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<Instant>, 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<Instant>, now: Instant): List<Int> {
|
||||
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 }
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<LocalDate?>(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)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Int> = 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()
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<PickerTarget?>(null) }
|
||||
var pickerTarget by rememberSaveable { mutableStateOf<PickerTarget?>(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<java.time.LocalDate?>(null) }
|
||||
var showTime by remember { mutableStateOf(false) }
|
||||
var pendingDate by rememberSaveable { mutableStateOf<java.time.LocalDate?>(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
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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<TaskList> = emptyList(),
|
||||
/** Lists the user has ticked *off*; everything else is included. */
|
||||
val excluded: Set<Long> = 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<Long>())
|
||||
private val running = MutableStateFlow(false)
|
||||
private val outcome = MutableStateFlow<ExportOutcome?>(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<ExportUiState> =
|
||||
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<ExportDocument>) -> 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<ListOverview>)
|
||||
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<ListsUiState> =
|
||||
@@ -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<ListWriteFailure?>(null)
|
||||
|
||||
/** Set when a list write is refused; the screen shows it and clears it. */
|
||||
val writeFailure: StateFlow<ListWriteFailure?> = _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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PermissionUiState> = _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<Unit> = callbackFlow {
|
||||
trySend(Unit)
|
||||
val handle = providerResolver.onModeChanged { trySend(Unit) }
|
||||
awaitClose { handle.close() }
|
||||
}.buffer(Channel.CONFLATED)
|
||||
|
||||
val state: StateFlow<PermissionUiState> =
|
||||
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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<TaskList> = 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<SettingsUiState> =
|
||||
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<StorageUiState?> =
|
||||
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) }
|
||||
|
||||
@@ -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()),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Task>,
|
||||
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<TaskListUiState> = 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<ListWriteFailure?>(null)
|
||||
|
||||
/** Set when a list write is refused; the screen shows it and clears it. */
|
||||
val listWriteFailure: StateFlow<ListWriteFailure?> = _listWriteFailure.asStateFlow()
|
||||
|
||||
private val _listDeleted = MutableStateFlow(false)
|
||||
|
||||
/** Flips once the list this screen shows is really gone, so it can leave. */
|
||||
val listDeleted: StateFlow<Boolean> = _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
|
||||
|
||||
@@ -122,7 +122,32 @@
|
||||
<string name="lists_header">Lists</string>
|
||||
<string name="new_task">New task</string>
|
||||
<string name="lists_failure">Could not read your tasks.</string>
|
||||
<string name="lists_empty">No task lists yet. Add one in your tasks app or with the + button.</string>
|
||||
<string name="lists_empty">No task lists yet.</string>
|
||||
<string name="lists_empty_action">Create a list</string>
|
||||
|
||||
<!-- Task lists: create, edit, delete -->
|
||||
<string name="list_add">New list</string>
|
||||
<string name="list_new_title">New list</string>
|
||||
<string name="list_edit_title">Edit list</string>
|
||||
<string name="list_name_hint">List name</string>
|
||||
<string name="list_color">Colour</string>
|
||||
<string name="list_delete">Delete list</string>
|
||||
<string name="list_save_failed">Could not save the list.</string>
|
||||
<string name="list_delete_failed">Could not delete the list.</string>
|
||||
<string name="list_delete_confirm_title">Delete list?</string>
|
||||
<string name="list_delete_confirm_message">“%1$s” and all of its tasks will be deleted. This can\'t be undone.</string>
|
||||
<string name="list_color_mauve">Mauve</string>
|
||||
<string name="list_color_red">Red</string>
|
||||
<string name="list_color_orange">Orange</string>
|
||||
<string name="list_color_amber">Amber</string>
|
||||
<string name="list_color_olive">Olive</string>
|
||||
<string name="list_color_green">Green</string>
|
||||
<string name="list_color_teal">Teal</string>
|
||||
<string name="list_color_cyan">Cyan</string>
|
||||
<string name="list_color_blue">Blue</string>
|
||||
<string name="list_color_indigo">Indigo</string>
|
||||
<string name="list_color_purple">Purple</string>
|
||||
<string name="list_color_pink">Pink</string>
|
||||
<string name="smart_today">Today</string>
|
||||
<string name="smart_overdue">Overdue</string>
|
||||
<string name="smart_upcoming">Upcoming</string>
|
||||
@@ -221,6 +246,37 @@
|
||||
<string name="settings_bottom_add_bar">Bottom quick-add bar</string>
|
||||
<string name="settings_bottom_add_bar_hint">Add tasks from a bar pinned to the bottom of a list, instead of the floating button</string>
|
||||
|
||||
<string name="onboarding_use_own_store">Use this device\'s storage instead</string>
|
||||
|
||||
<!-- Storage and export -->
|
||||
<string name="settings_section_storage">Storage</string>
|
||||
<string name="settings_storage_subtitle">Where tasks are kept, and export</string>
|
||||
<string name="settings_task_store">Task store</string>
|
||||
<string name="settings_task_store_hint">Each store keeps its own tasks. Switching does not move them across — export first if you want a copy.</string>
|
||||
<string name="settings_store_own">On this device</string>
|
||||
<string name="settings_store_own_hint">Agendula\'s own storage. Nothing else to install.</string>
|
||||
<string name="settings_store_external">Another task app</string>
|
||||
<string name="settings_store_external_hint">Share tasks with the app that syncs them</string>
|
||||
<string name="settings_store_external_missing">No compatible task app is installed</string>
|
||||
<string name="settings_store_permission_denied">Permission denied</string>
|
||||
<string name="settings_store_permission_denied_hint">The other app\'s tasks stay unreachable until you allow access. Tap to open app settings.</string>
|
||||
<string name="settings_export">Export tasks</string>
|
||||
<string name="settings_export_hint">Save your lists as iCalendar files</string>
|
||||
<string name="export_hint">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.</string>
|
||||
<string name="export_no_lists">No lists to export</string>
|
||||
<string name="export_to_folder">Save to a folder</string>
|
||||
<string name="export_to_zip">Save as a zip file</string>
|
||||
<string name="export_running">Exporting…</string>
|
||||
<plurals name="export_done">
|
||||
<item quantity="one">Exported %1$d list</item>
|
||||
<item quantity="other">Exported %1$d lists</item>
|
||||
</plurals>
|
||||
<string name="export_failed">The export could not be written</string>
|
||||
<string name="export_failed_folder">The chosen folder could not be opened</string>
|
||||
<string name="export_failed_read_only">The chosen folder is not writable</string>
|
||||
<string name="export_failed_create">A file could not be created in the chosen folder</string>
|
||||
<string name="export_failed_access">Access to the chosen location was lost</string>
|
||||
|
||||
<!-- Reminder lead times (custom amounts) -->
|
||||
<plurals name="reminder_minutes">
|
||||
<item quantity="one">%1$d minute before</item>
|
||||
|
||||
@@ -1,4 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<full-backup-content>
|
||||
<!-- No file-based backups; settings live in DataStore which is backed up by default. -->
|
||||
<!--
|
||||
Agendula's own task store. Room runs in WAL mode and Auto Backup copies
|
||||
files without checkpointing, so the `-wal` sidecar can hold writes the
|
||||
`.db` alone does not — all three go in together, and the app checkpoints
|
||||
on ON_STOP so a restore is consistent either way.
|
||||
|
||||
Naming any <include> makes everything else excluded by default, so the
|
||||
archived dmfs database (`tasks.db.imported`, kept one release as the
|
||||
import's rollback path) is already left out. An explicit <exclude> for it
|
||||
would be redundant *and* rejected — lint's FullBackupContent check errors
|
||||
on an exclude that sits under no included path.
|
||||
|
||||
Settings live in DataStore, which this exclusion now also covers, so its
|
||||
sharedpref file is listed back in.
|
||||
-->
|
||||
<include domain="database" path="agendula-tasks.db" />
|
||||
<include domain="database" path="agendula-tasks.db-wal" />
|
||||
<include domain="database" path="agendula-tasks.db-shm" />
|
||||
<include domain="file" path="datastore/" />
|
||||
</full-backup-content>
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<data-extraction-rules>
|
||||
<!--
|
||||
See backup_rules.xml: the WAL sidecars travel with the database, and
|
||||
naming any <include> makes everything else excluded by default — which is
|
||||
what keeps the archived `tasks.db.imported` out without an <exclude> that
|
||||
lint would reject.
|
||||
-->
|
||||
<cloud-backup>
|
||||
<!-- Allow DataStore backup, exclude nothing extra. -->
|
||||
<include domain="database" path="agendula-tasks.db" />
|
||||
<include domain="database" path="agendula-tasks.db-wal" />
|
||||
<include domain="database" path="agendula-tasks.db-shm" />
|
||||
<include domain="file" path="datastore/" />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<include domain="database" path="agendula-tasks.db" />
|
||||
<include domain="database" path="agendula-tasks.db-wal" />
|
||||
<include domain="database" path="agendula-tasks.db-shm" />
|
||||
<include domain="file" path="datastore/" />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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<String, String> = emptyMap(),
|
||||
val granted: Set<String> = 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<String, String> = emptyMap(),
|
||||
granted: Set<String> = 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String, Any?>) =
|
||||
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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String> = 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<Task>
|
||||
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<List<TaskList>>
|
||||
fun tasks(filter: TaskFilter): Flow<List<Task>>
|
||||
fun subtasks(parentId: Long): Flow<List<Task>>
|
||||
fun taskDetail(taskId: Long): Flow<TaskDetail?>
|
||||
|
||||
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
|
||||
> `<permission>` 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<RoomTasksDataSource>` and `Provider<AndroidTasksDataSource>` — 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).
|
||||
- **`<queries>`** 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 `<provider>` 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.
|
||||
- **`<queries>`** 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.
|
||||
|
||||
621
docs/OWN-STORE.md
Normal file
621
docs/OWN-STORE.md
Normal file
@@ -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 `<provider>`, 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-<rowid>@…`, 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<Task>
|
||||
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 `<provider>` 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.
|
||||
19
docs/PLAN.md
19
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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
185
docs/ROADMAP.md
185
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 `<provider>`,
|
||||
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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
284
docs/STORAGE-DECISION.md
Normal file
284
docs/STORAGE-DECISION.md
Normal file
@@ -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.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user