Compare commits
22 Commits
main
...
feat/own-s
| Author | SHA1 | Date | |
|---|---|---|---|
| fdbc236ab4 | |||
| ec2e2eb59d | |||
| 9ff6027e50 | |||
| fcee1d1736 | |||
| 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
|
||||||
.ksp/
|
.ksp/
|
||||||
|
|
||||||
|
# Local agent notes: machine-specific build setup and on-device rules, not
|
||||||
|
# anything the project itself depends on.
|
||||||
|
/CLAUDE.md
|
||||||
|
|||||||
@@ -7,14 +7,7 @@ All notable changes to this project are documented here. The format follows
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
## [0.4.0] - 2026-08-31
|
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Agendula now speaks **German** and **Brazilian Portuguese**, the first
|
|
||||||
community translations. Pick one under **Settings → App language**, or in
|
|
||||||
Android's own per-app language settings; leave it on *System default* and
|
|
||||||
Agendula follows your phone. Anything not yet translated falls back to
|
|
||||||
English.
|
|
||||||
- Agendula can now be translated. Pick or request a language on Weblate and
|
- Agendula can now be translated. Pick or request a language on Weblate and
|
||||||
translate in the browser — the link sits at the top of the language picker in
|
translate in the browser — the link sits at the top of the language picker in
|
||||||
**Settings → App language**. Partial translations are fine; anything
|
**Settings → App language**. Partial translations are fine; anything
|
||||||
|
|||||||
56
README.md
56
README.md
@@ -3,8 +3,8 @@
|
|||||||
<h1>Agendula</h1>
|
<h1>Agendula</h1>
|
||||||
|
|
||||||
<p><strong>A modern Material 3 Expressive task app for Android.</strong><br>
|
<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
|
Keeps your tasks on your device, or on top of a tasks provider you already use.
|
||||||
sync stack.</p>
|
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>
|
<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+">
|
<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>
|
</div>
|
||||||
|
|
||||||
Agendula is the task-list sibling to [Calendula](https://codeberg.org/jlmakiola/calendula).
|
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
|
Where Calendula is a pure front-end over Android's `CalendarContract`, Agendula
|
||||||
a pure front-end over the **OpenTasks `TaskContract` provider** — the store that
|
keeps its own store, designed around RFC 5545's `VTODO` — the same tasks DAVx5
|
||||||
DAVx5 (and SmoothSync, DecSync, …) syncs your CalDAV `VTODO` tasks into. No own
|
(and SmoothSync, DecSync, …) sync out of your CalDAV server. It can also read and
|
||||||
database, no reinvented sync.
|
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
|
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;
|
“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
|
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.)
|
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
|
## Where your tasks live — your choice
|
||||||
> the `TaskContract` provider — provider resolution, live-updating reads,
|
|
||||||
> writes, smart-list filtering, and a self-scheduled reminder engine — is built
|
| | Where | Sync | Needs |
|
||||||
> 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,
|
| **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/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)
|
## Sync sources (by design)
|
||||||
|
|
||||||
Agendula works with anything that writes to the tasks provider — **DAVx5**
|
In external-provider mode Agendula works with anything that writes to that provider —
|
||||||
(CalDAV), **SmoothSync**, **CalDAV-Sync**, **DecSync CC**, or any Android sync
|
**DAVx5** (CalDAV), **SmoothSync**, **CalDAV-Sync**, **DecSync CC**, or any
|
||||||
adapter — because it builds on the provider, not on any one sync app. Google
|
Android sync adapter — because it builds on the provider, not on any one sync
|
||||||
Tasks / Microsoft To Do are out of scope by design (proprietary; they would mean
|
app. Google Tasks / Microsoft To Do are out of scope by design (proprietary; they
|
||||||
owning a sync stack). Open standards — CalDAV / iCalendar / DecSync — are the lane.
|
would mean owning a sync stack). Open standards — CalDAV / iCalendar / DecSync —
|
||||||
|
are the lane.
|
||||||
|
|
||||||
## Translations
|
## Translations
|
||||||
|
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ android {
|
|||||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||||
// PATCH from versionName, e.g. 0.2.0 -> 200). The Gitea release is marked
|
// PATCH from versionName, e.g. 0.2.0 -> 200). The Gitea release is marked
|
||||||
// as a pre-release while MAJOR is 0. See docs/RELEASING.md.
|
// as a pre-release while MAJOR is 0. See docs/RELEASING.md.
|
||||||
versionCode = 400
|
versionCode = 302
|
||||||
versionName = "0.4.0"
|
versionName = "0.3.2"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@@ -129,6 +129,10 @@ android {
|
|||||||
isReturnDefaultValues = true
|
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 {
|
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 {
|
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.core.ktx)
|
||||||
implementation(libs.androidx.appcompat)
|
implementation(libs.androidx.appcompat)
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
|
implementation(libs.androidx.lifecycle.process)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
|
|
||||||
implementation(platform(libs.androidx.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
@@ -157,7 +177,17 @@ dependencies {
|
|||||||
implementation(libs.androidx.navigation.compose)
|
implementation(libs.androidx.navigation.compose)
|
||||||
ksp(libs.hilt.compiler)
|
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.datastore.preferences)
|
||||||
|
implementation(libs.androidx.documentfile)
|
||||||
|
|
||||||
implementation(libs.androidx.glance.appwidget)
|
implementation(libs.androidx.glance.appwidget)
|
||||||
implementation(libs.androidx.glance.material3)
|
implementation(libs.androidx.glance.material3)
|
||||||
@@ -185,6 +215,7 @@ dependencies {
|
|||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
androidTestImplementation(libs.androidx.test.rules)
|
androidTestImplementation(libs.androidx.test.rules)
|
||||||
androidTestImplementation(libs.truth)
|
androidTestImplementation(libs.truth)
|
||||||
|
androidTestImplementation(libs.androidx.room.testing)
|
||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
androidTestImplementation(libs.androidx.ui.test.junit4)
|
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"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
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
|
(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.READ_TASKS" />
|
||||||
<uses-permission android:name="org.dmfs.permission.WRITE_TASKS" />
|
<uses-permission android:name="org.dmfs.permission.WRITE_TASKS" />
|
||||||
<uses-permission android:name="org.tasks.permission.READ_TASKS" />
|
<uses-permission android:name="org.tasks.permission.READ_TASKS" />
|
||||||
@@ -74,8 +80,11 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
<!-- Re-sync reminders when the provider changes (external DAVx5 sync).
|
<!-- Re-sync reminders when an external provider changes — DAVx5 pulling
|
||||||
Targets both known authorities; the host must be static. -->
|
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
|
<receiver
|
||||||
android:name=".data.reminders.ProviderChangeReceiver"
|
android:name=".data.reminders.ProviderChangeReceiver"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
package de.jeanlucmakiola.agendula
|
package de.jeanlucmakiola.agendula
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.ProcessLifecycleOwner
|
||||||
import dagger.hilt.EntryPoint
|
import dagger.hilt.EntryPoint
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.android.EntryPointAccessors
|
import dagger.hilt.android.EntryPointAccessors
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
||||||
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
|
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.CrashConfig
|
||||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Application entry point. Registered as android:name=".AgendulaApp". Besides
|
* Application entry point. Registered as android:name=".AgendulaApp". Besides
|
||||||
@@ -35,17 +39,42 @@ class AgendulaApp : Application() {
|
|||||||
issueTitle = getString(R.string.crash_report_issue_title),
|
issueTitle = getString(R.string.crash_report_issue_title),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
val scheduler = EntryPointAccessors
|
val entryPoint = EntryPointAccessors.fromApplication(this, AppEntryPoint::class.java)
|
||||||
.fromApplication(this, ReminderEntryPoint::class.java)
|
val scheduler = entryPoint.reminderScheduler()
|
||||||
.reminderScheduler()
|
// Mirror the stored storage mode into ProviderResolver and import a
|
||||||
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
|
// v0.3.x install's tasks, both before anything reads a store.
|
||||||
runCatching { scheduler.sync() }
|
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
|
@EntryPoint
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
interface ReminderEntryPoint {
|
interface AppEntryPoint {
|
||||||
fun reminderScheduler(): ReminderScheduler
|
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))
|
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)))
|
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 {
|
private companion object {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package de.jeanlucmakiola.agendula.data.di
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
import dagger.Binds
|
import dagger.Binds
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
@@ -10,12 +12,21 @@ import dagger.Provides
|
|||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import dagger.hilt.components.SingletonComponent
|
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.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.TasksDataSource
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepositoryImpl
|
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.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import javax.inject.Provider
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
private val Context.agendulaDataStore: DataStore<Preferences> by preferencesDataStore(
|
private val Context.agendulaDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||||
@@ -28,11 +39,11 @@ abstract class DataBindModule {
|
|||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
@Singleton
|
@Singleton
|
||||||
abstract fun bindTasksDataSource(impl: AndroidTasksDataSource): TasksDataSource
|
abstract fun bindTasksRepository(impl: TasksRepositoryImpl): TasksRepository
|
||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
@Singleton
|
@Singleton
|
||||||
abstract fun bindTasksRepository(impl: TasksRepositoryImpl): TasksRepository
|
abstract fun bindProviderEnvironment(impl: AndroidProviderEnvironment): ProviderEnvironment
|
||||||
}
|
}
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@@ -44,7 +55,41 @@ object DataProvideModule {
|
|||||||
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
||||||
context.agendulaDataStore
|
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
|
@Provides
|
||||||
@IoDispatcher
|
@IoDispatcher
|
||||||
fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
|
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
|
@Qualifier
|
||||||
@Retention(AnnotationRetention.BINARY)
|
@Retention(AnnotationRetention.BINARY)
|
||||||
annotation class IoDispatcher
|
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.longPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec
|
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 }
|
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. */
|
/** One-time reminder onboarding gate; false until the step has been shown. */
|
||||||
val reminderOnboardingDone: Flow<Boolean> = dataStore.data.map { it[REMINDER_ONBOARDING_DONE] ?: false }
|
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 SHOW_ADD_SUBTASK_ROW = booleanPreferencesKey("show_add_subtask_row")
|
||||||
val BOTTOM_ADD_BAR = booleanPreferencesKey("bottom_add_bar")
|
val BOTTOM_ADD_BAR = booleanPreferencesKey("bottom_add_bar")
|
||||||
val REMINDER_ONBOARDING_DONE = booleanPreferencesKey("reminder_onboarding_done")
|
val REMINDER_ONBOARDING_DONE = booleanPreferencesKey("reminder_onboarding_done")
|
||||||
|
val STORAGE_MODE = stringPreferencesKey("storage_mode")
|
||||||
val LIST_REMINDER_OVERRIDE = stringPreferencesKey("list_reminder_override")
|
val LIST_REMINDER_OVERRIDE = stringPreferencesKey("list_reminder_override")
|
||||||
val DEFAULT_EDIT_FIELDS = stringSetPreferencesKey("default_edit_fields")
|
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.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import androidx.core.net.toUri
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||||
@@ -45,7 +46,14 @@ class DueReminderReceiver : BroadcastReceiver() {
|
|||||||
companion object {
|
companion object {
|
||||||
private const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID"
|
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.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.os.SystemClock
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -20,10 +22,25 @@ import javax.inject.Inject
|
|||||||
class ProviderChangeReceiver : BroadcastReceiver() {
|
class ProviderChangeReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
@Inject lateinit var scheduler: ReminderScheduler
|
@Inject lateinit var scheduler: ReminderScheduler
|
||||||
|
@Inject lateinit var providerResolver: ProviderResolver
|
||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
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()
|
val pending = goAsync()
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
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 de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The self-scheduled due-reminder engine. Tasks providers don't deliver
|
* The self-scheduled due-reminder engine. Nothing else delivers task reminders —
|
||||||
* reminders, so Agendula reads upcoming due tasks and arms one exact [AlarmManager]
|
* not the platform, not a tasks provider — so Agendula reads upcoming due tasks
|
||||||
* alarm each, within a rolling window. Re-run on app start, boot and provider
|
* and arms one exact [AlarmManager] alarm each, within a rolling window. Re-run
|
||||||
* change; it diffs against [ScheduledReminderStore] so only changed alarms move.
|
* 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
|
@Singleton
|
||||||
class ReminderScheduler @Inject constructor(
|
class ReminderScheduler @Inject constructor(
|
||||||
@@ -31,47 +34,89 @@ class ReminderScheduler @Inject constructor(
|
|||||||
private val providerResolver: ProviderResolver,
|
private val providerResolver: ProviderResolver,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) {
|
) {
|
||||||
suspend fun sync() = withContext(io) {
|
private val syncLock = Mutex()
|
||||||
val provider = providerResolver.resolve()
|
|
||||||
|
/**
|
||||||
|
* 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()
|
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()
|
clearAll()
|
||||||
return@withContext
|
return
|
||||||
}
|
}
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val horizon = now + WINDOW_MS
|
val horizon = now + WINDOW_MS
|
||||||
val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) }
|
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
|
val desired = tasks
|
||||||
.filter { !it.isClosed && it.due != null }
|
.filter { !it.isClosed && it.due != null }
|
||||||
.mapNotNull { task ->
|
.mapNotNull { task ->
|
||||||
// The task's list may override the global lead, or opt out entirely
|
// A reminder set on the task itself wins; otherwise the task's list
|
||||||
// (override = null), in which case it gets no reminder at all.
|
// may override the global lead, or opt out entirely (override =
|
||||||
val lead = settings.reminderLeadFor(task.listId) ?: return@mapNotNull null
|
// null), in which case it gets no reminder at all.
|
||||||
task.taskId to (task.due!!.toEpochMilliseconds() - lead.coerceAtLeast(0) * 60_000L)
|
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()
|
// The lower bound trails `now` so a reminder missed while the device was
|
||||||
.filterValues { it in now..horizon }
|
// 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()
|
val previous = store.all()
|
||||||
(previous.keys - desired.keys).forEach { cancel(it) }
|
(previous - desired).forEach { cancel(it) }
|
||||||
desired.forEach { (taskId, triggerAt) ->
|
(desired - previous).forEach { schedule(it) }
|
||||||
if (previous[taskId] != triggerAt) schedule(taskId, triggerAt)
|
|
||||||
}
|
|
||||||
store.replace(desired)
|
store.replace(desired)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun alarmManager(): AlarmManager = context.getSystemService(AlarmManager::class.java)
|
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
|
val flags = (if (create) PendingIntent.FLAG_UPDATE_CURRENT else PendingIntent.FLAG_NO_CREATE) or
|
||||||
PendingIntent.FLAG_IMMUTABLE
|
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) {
|
private fun schedule(reminder: ScheduledReminder) {
|
||||||
val pi = pendingIntent(taskId, create = true) ?: return
|
val triggerAt = reminder.triggerAt
|
||||||
|
val pi = pendingIntent(reminder, create = true) ?: return
|
||||||
val am = alarmManager()
|
val am = alarmManager()
|
||||||
val canExact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms()
|
val canExact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms()
|
||||||
if (canExact) {
|
if (canExact) {
|
||||||
@@ -81,19 +126,21 @@ class ReminderScheduler @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun cancel(taskId: Long) {
|
private fun cancel(reminder: ScheduledReminder) {
|
||||||
pendingIntent(taskId, create = false)?.let {
|
pendingIntent(reminder, create = false)?.let {
|
||||||
alarmManager().cancel(it)
|
alarmManager().cancel(it)
|
||||||
it.cancel()
|
it.cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun clearAll() {
|
private suspend fun clearAll() {
|
||||||
store.all().keys.forEach { cancel(it) }
|
store.all().forEach { cancel(it) }
|
||||||
store.replace(emptyMap())
|
store.replace(emptySet())
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val WINDOW_MS = 30L * 24 * 60 * 60 * 1000 // 30 days
|
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
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remembers which task reminders are currently scheduled (taskId → trigger time),
|
* One armed alarm. A recurring task has many occurrences sharing a [taskId], so
|
||||||
* so [ReminderScheduler] can diff against a fresh computation and cancel only the
|
* the trigger time is part of the identity — keying by task alone would collapse
|
||||||
* alarms that changed. Persisted in DataStore as a set of `taskId|trigger` strings.
|
* 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
|
@Singleton
|
||||||
class ScheduledReminderStore @Inject constructor(
|
class ScheduledReminderStore @Inject constructor(
|
||||||
private val dataStore: DataStore<Preferences>,
|
private val dataStore: DataStore<Preferences>,
|
||||||
) {
|
) {
|
||||||
suspend fun all(): Map<Long, Long> =
|
suspend fun all(): Set<ScheduledReminder> =
|
||||||
dataStore.data.first()[KEY].orEmpty().mapNotNull { entry ->
|
dataStore.data.first()[KEY].orEmpty().mapNotNull { entry ->
|
||||||
val parts = entry.split('|')
|
val parts = entry.split('|')
|
||||||
val id = parts.getOrNull(0)?.toLongOrNull()
|
val id = parts.getOrNull(0)?.toLongOrNull()
|
||||||
val at = parts.getOrNull(1)?.toLongOrNull()
|
val at = parts.getOrNull(1)?.toLongOrNull()
|
||||||
if (id != null && at != null) id to at else null
|
if (id != null && at != null) ScheduledReminder(id, at) else null
|
||||||
}.toMap()
|
}.toSet()
|
||||||
|
|
||||||
suspend fun replace(scheduled: Map<Long, Long>) {
|
suspend fun replace(scheduled: Set<ScheduledReminder>) {
|
||||||
dataStore.edit { prefs ->
|
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 dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
|
import de.jeanlucmakiola.agendula.domain.export.ExportTask
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The only class that knows about the ContentResolver, [TasksContract] and the
|
* The only class that knows about the ContentResolver, [TasksContract] and the
|
||||||
@@ -83,6 +86,26 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
} ?: emptyList()
|
} ?: 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 ---------------------------------------------------------------
|
// --- writes ---------------------------------------------------------------
|
||||||
|
|
||||||
override fun insertTask(form: TaskForm): Long {
|
override fun insertTask(form: TaskForm): Long {
|
||||||
@@ -98,12 +121,109 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
if (rows == 0) throw TaskWriteFailedException("update task $taskId")
|
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) {
|
override fun setCompleted(taskId: Long, completed: Boolean) {
|
||||||
val values = TaskWriteMapper.completionValues(completed, System.currentTimeMillis())
|
val values = TaskWriteMapper.completionValues(completed, System.currentTimeMillis())
|
||||||
val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
|
val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
|
||||||
if (rows == 0) throw TaskWriteFailedException("complete task $taskId")
|
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) {
|
override fun deleteTask(taskId: Long) {
|
||||||
resolver.delete(taskUri(authority(), taskId), null, null)
|
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")
|
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 ----------------------------------------------------------
|
// --- observation ----------------------------------------------------------
|
||||||
|
|
||||||
override fun registerObserver(onChange: () -> Unit): AutoCloseable {
|
override fun registerObserver(onChange: () -> Unit): AutoCloseable {
|
||||||
@@ -127,9 +272,16 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
val observer = object : ContentObserver(Handler(Looper.getMainLooper())) {
|
val observer = object : ContentObserver(Handler(Looper.getMainLooper())) {
|
||||||
override fun onChange(selfChange: Boolean) = onChange()
|
override fun onChange(selfChange: Boolean) = onChange()
|
||||||
}
|
}
|
||||||
|
// 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.instancesUri(provider.authority), true, observer)
|
||||||
resolver.registerContentObserver(TasksContract.listsUri(provider.authority), true, observer)
|
resolver.registerContentObserver(TasksContract.listsUri(provider.authority), true, observer)
|
||||||
return AutoCloseable { resolver.unregisterContentObserver(observer) }
|
} catch (e: RuntimeException) {
|
||||||
|
runCatching { resolver.unregisterContentObserver(observer) }
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
return AutoCloseable { runCatching { resolver.unregisterContentObserver(observer) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Map<String, Any?>.toContentValues(): ContentValues {
|
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
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
import android.content.Context
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A tasks provider Agendula can talk to. The same dmfs `TaskProvider` backs every
|
* An external tasks provider Agendula can talk to. Every candidate runs the same
|
||||||
* candidate, so the [TasksContract] columns apply regardless of which is present.
|
* dmfs `TaskProvider`, so the [TasksContract] columns apply to either.
|
||||||
*/
|
*/
|
||||||
data class TaskProvider(
|
data class TaskProvider(
|
||||||
val authority: String,
|
val authority: String,
|
||||||
@@ -19,41 +16,122 @@ data class TaskProvider(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The A/B seam. Detects which tasks provider is installed at runtime and which
|
* Discovers the *external* tasks providers (OpenTasks, tasks.org) that
|
||||||
* permission set it needs, so nothing above the data layer hardcodes an
|
* [StorageMode.EXTERNAL] can be pointed at.
|
||||||
* authority. Under Posture B (bundled provider) this simply finds our own
|
*
|
||||||
* `org.dmfs.tasks` first. See docs/PLAN.md.
|
* 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
|
@Singleton
|
||||||
class ProviderResolver @Inject constructor(
|
class ProviderResolver @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
private val environment: ProviderEnvironment,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/** The active provider, or `null` when no tasks provider is installed. */
|
private val modeListeners = CopyOnWriteArrayList<() -> Unit>()
|
||||||
fun resolve(): TaskProvider? {
|
|
||||||
for (candidate in CANDIDATES) {
|
/**
|
||||||
val info = context.packageManager.resolveContentProvider(candidate.authority, 0)
|
* The user's explicit choice, or `null` while they have not made one (which is
|
||||||
?: continue
|
* the normal state — most people never open Settings). Kept as a plain field
|
||||||
return candidate.copy(packageName = info.packageName)
|
* 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
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun hasPermission(provider: TaskProvider): Boolean =
|
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 {
|
companion object {
|
||||||
/**
|
/**
|
||||||
* Verified on-device: tasks.org exposes `org.tasks.opentasks` backed by
|
* Verified on-device: tasks.org exposes `org.tasks.opentasks` backed by
|
||||||
* `org.dmfs.provider.tasks.TaskProvider`, guarded by
|
* `org.dmfs.provider.tasks.TaskProvider`, guarded by `org.tasks.permission.*`
|
||||||
* `org.tasks.permission.*` (dangerous). OpenTasks uses `org.dmfs.tasks`
|
* (dangerous). OpenTasks uses `org.dmfs.tasks` + `org.dmfs.permission.*`.
|
||||||
* + `org.dmfs.permission.*`. OpenTasks is listed first as the canonical
|
* OpenTasks is listed first as the canonical authority; on a device with
|
||||||
* authority; on a device with only one installed, order is moot.
|
* only one installed, order is moot.
|
||||||
*/
|
*/
|
||||||
val CANDIDATES: List<TaskProvider> = listOf(
|
val EXTERNAL_CANDIDATES: List<TaskProvider> = listOf(
|
||||||
TaskProvider(
|
TaskProvider(
|
||||||
authority = "org.dmfs.tasks",
|
authority = "org.dmfs.tasks",
|
||||||
readPermission = "org.dmfs.permission.READ_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.data.tasks.TasksContract.Tasks
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
|
import de.jeanlucmakiola.agendula.domain.export.ExportTask
|
||||||
import de.jeanlucmakiola.agendula.domain.priorityFromICal
|
import de.jeanlucmakiola.agendula.domain.priorityFromICal
|
||||||
import de.jeanlucmakiola.agendula.domain.statusFromInt
|
import de.jeanlucmakiola.agendula.domain.statusFromInt
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
@@ -16,10 +17,17 @@ object TaskMapper {
|
|||||||
fun instant(name: String): Instant? =
|
fun instant(name: String): Instant? =
|
||||||
r.getLong(name)?.let { Instant.fromEpochMilliseconds(it) }
|
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(
|
return Task(
|
||||||
id = instanceId,
|
taskId = r.getLong(Instances.TASK_ID) ?: rowId,
|
||||||
taskId = r.getLong(Instances.TASK_ID) ?: instanceId,
|
|
||||||
listId = r.getLong(Tasks.LIST_ID) ?: 0L,
|
listId = r.getLong(Tasks.LIST_ID) ?: 0L,
|
||||||
title = r.getString(Tasks.TITLE).orEmpty(),
|
title = r.getString(Tasks.TITLE).orEmpty(),
|
||||||
description = r.getString(Tasks.DESCRIPTION),
|
description = r.getString(Tasks.DESCRIPTION),
|
||||||
@@ -38,13 +46,66 @@ object TaskMapper {
|
|||||||
listName = r.getString(Tasks.LIST_NAME),
|
listName = r.getString(Tasks.LIST_NAME),
|
||||||
accountName = r.getString(Tasks.ACCOUNT_NAME),
|
accountName = r.getString(Tasks.ACCOUNT_NAME),
|
||||||
parentId = r.getLong(Tasks.PARENT_ID),
|
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),
|
distanceFromCurrent = r.getInt(Instances.DISTANCE_FROM_CURRENT),
|
||||||
created = instant(Tasks.CREATED),
|
created = instant(Tasks.CREATED),
|
||||||
lastModified = instant(Tasks.LAST_MODIFIED),
|
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(
|
fun taskList(r: ColumnReader): TaskList = TaskList(
|
||||||
id = r.getLong(Lists.ID) ?: 0L,
|
id = r.getLong(Lists.ID) ?: 0L,
|
||||||
name = r.getString(Lists.NAME).orEmpty(),
|
name = r.getString(Lists.NAME).orEmpty(),
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.tasks
|
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.Lists
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
|
||||||
|
|
||||||
/** Column lists requested from the provider. Order is irrelevant; we read by name. */
|
/** Column lists requested from the provider. Order is irrelevant; we read by name. */
|
||||||
object TaskProjections {
|
object TaskProjections {
|
||||||
@@ -18,31 +16,9 @@ object TaskProjections {
|
|||||||
Lists.ACCOUNT_TYPE,
|
Lists.ACCOUNT_TYPE,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Read from the `instances` view (inherits all task columns). */
|
// No `instances` projection on purpose: that read passes `projection = null`
|
||||||
val INSTANCES: Array<String> = arrayOf(
|
// (all columns), because the view's shape differs across provider versions —
|
||||||
Tasks.ID,
|
// tasks.org's bundled OpenTasks has no `is_recurring`, for one. A fixed list
|
||||||
Instances.TASK_ID,
|
// here would drift out of sync with the by-name mapper and quietly drop
|
||||||
Tasks.LIST_ID,
|
// columns it depends on. See AndroidTasksDataSource.queryInstances.
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.tasks
|
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.Lists
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
import de.jeanlucmakiola.agendula.domain.toICal
|
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),
|
* 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.IS_ALLDAY, if (form.isAllDay) 1 else 0)
|
||||||
put(Tasks.DTSTART, form.start?.toEpochMilliseconds())
|
// All-day tasks are date-only in iCalendar. The provider reads them back
|
||||||
put(Tasks.DUE, form.due?.toEpochMilliseconds())
|
// 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)
|
put(Tasks.PARENT_ID, form.parentId)
|
||||||
// The provider treats a null tz as local time; set it explicitly for
|
// The provider treats a null tz as local time; set it explicitly for
|
||||||
// timed tasks so the stored instant is unambiguous across zones.
|
// timed tasks so the stored instant is unambiguous across zones.
|
||||||
@@ -48,6 +69,16 @@ object TaskWriteMapper {
|
|||||||
put(Tasks.TZ, if (timed) tzId else null)
|
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?> =
|
fun completionValues(completed: Boolean, nowMillis: Long): Map<String, Any?> =
|
||||||
if (completed) {
|
if (completed) {
|
||||||
mapOf(
|
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.NAME to name.trim(),
|
||||||
Lists.COLOR to color,
|
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_NAME to TasksContract.LOCAL_ACCOUNT_NAME,
|
||||||
Lists.ACCOUNT_TYPE to TasksContract.LOCAL_ACCOUNT_TYPE,
|
Lists.ACCOUNT_TYPE to TasksContract.LOCAL_ACCOUNT_TYPE,
|
||||||
Lists.VISIBLE to 1,
|
Lists.VISIBLE to 1,
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ object TasksContract {
|
|||||||
const val IS_ALLDAY = "is_allday"
|
const val IS_ALLDAY = "is_allday"
|
||||||
const val TZ = "tz"
|
const val TZ = "tz"
|
||||||
const val RRULE = "rrule"
|
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 PARENT_ID = "parent_id"
|
||||||
const val SORTING = "sorting"
|
const val SORTING = "sorting"
|
||||||
const val CREATED = "created"
|
const val CREATED = "created"
|
||||||
@@ -96,8 +99,59 @@ object TasksContract {
|
|||||||
const val INSTANCE_DUE_SORTING = "instance_due_sorting"
|
const val INSTANCE_DUE_SORTING = "instance_due_sorting"
|
||||||
const val DISTANCE_FROM_CURRENT = "distance_from_current"
|
const val DISTANCE_FROM_CURRENT = "distance_from_current"
|
||||||
const val IS_RECURRING = "is_recurring"
|
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_*) --------------------------------
|
// --- status values (TaskColumns.STATUS_*) --------------------------------
|
||||||
const val STATUS_NEEDS_ACTION = 0
|
const val STATUS_NEEDS_ACTION = 0
|
||||||
const val STATUS_IN_PROCESS = 1
|
const val STATUS_IN_PROCESS = 1
|
||||||
@@ -109,9 +163,20 @@ object TasksContract {
|
|||||||
|
|
||||||
fun authorityUri(authority: String): Uri = Uri.parse("content://$authority")
|
fun authorityUri(authority: String): Uri = Uri.parse("content://$authority")
|
||||||
fun listsUri(authority: String): Uri = Uri.parse("content://$authority/${Lists.PATH}")
|
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 tasksUri(authority: String): Uri = Uri.parse("content://$authority/${Tasks.PATH}")
|
||||||
fun instancesUri(authority: String): Uri = Uri.parse("content://$authority/${Instances.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. */
|
/** Append the sync-adapter params required to write local-account rows. */
|
||||||
fun asSyncAdapter(uri: Uri, accountName: String, accountType: String): Uri =
|
fun asSyncAdapter(uri: Uri, accountName: String, accountType: String): Uri =
|
||||||
uri.buildUpon()
|
uri.buildUpon()
|
||||||
|
|||||||
@@ -3,6 +3,17 @@ package de.jeanlucmakiola.agendula.data.tasks
|
|||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
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. */
|
/** What to fetch from the provider. Smart-list date logic is applied above this. */
|
||||||
data class TaskQuery(
|
data class TaskQuery(
|
||||||
@@ -23,10 +34,67 @@ interface TasksDataSource {
|
|||||||
|
|
||||||
fun insertTask(form: TaskForm): Long
|
fun insertTask(form: TaskForm): Long
|
||||||
fun updateTask(taskId: Long, form: TaskForm)
|
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)
|
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 deleteTask(taskId: Long)
|
||||||
fun createLocalList(name: String, color: Int): 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. */
|
/** Observe any change to tasks/lists; [onChange] fires on a background thread. */
|
||||||
fun registerObserver(onChange: () -> Unit): AutoCloseable
|
fun registerObserver(onChange: () -> Unit): AutoCloseable
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,9 +37,25 @@ interface TasksRepository {
|
|||||||
* since the form loaded. Pass `null` to force the write (overwrite-anyway).
|
* since the form loaded. Pass `null` to force the write (overwrite-anyway).
|
||||||
*/
|
*/
|
||||||
suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant? = null)
|
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)
|
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 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. */
|
/** Synchronous snapshot for the permission/onboarding gate. */
|
||||||
fun providerStatus(): ProviderStatus
|
fun providerStatus(): ProviderStatus
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import kotlin.time.Instant
|
|||||||
class TasksRepositoryImpl @Inject constructor(
|
class TasksRepositoryImpl @Inject constructor(
|
||||||
private val dataSource: TasksDataSource,
|
private val dataSource: TasksDataSource,
|
||||||
private val providerResolver: ProviderResolver,
|
private val providerResolver: ProviderResolver,
|
||||||
|
private val startupGate: StartupGate,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : TasksRepository {
|
) : TasksRepository {
|
||||||
|
|
||||||
@@ -80,22 +81,50 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun createTask(form: TaskForm): Long =
|
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?) =
|
override suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant?) =
|
||||||
withContext(io) {
|
withContext(io) {
|
||||||
// Conflict-safe overwrite: re-read just before writing and bail if the
|
// Re-read just before writing: it settles the conflict check *and* tells
|
||||||
// provider's last_modified moved since the form captured it (external
|
// us which URI to write through.
|
||||||
// sync / another app). A null baseline means "force / overwrite anyway".
|
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) {
|
if (expectedLastModified != null) {
|
||||||
val current = dataSource.task(taskId)?.lastModified
|
val seen = current?.lastModified
|
||||||
if (current != null && current != expectedLastModified) throw TaskConflictException(taskId)
|
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) =
|
override suspend fun setCompleted(taskId: Long, occurrenceStart: Instant?, completed: Boolean) =
|
||||||
withContext(io) { dataSource.setCompleted(taskId, completed) }
|
withContext(io) {
|
||||||
|
if (occurrenceStart != null) {
|
||||||
|
dataSource.setCompletedInstance(taskId, occurrenceStart, completed)
|
||||||
|
} else {
|
||||||
|
dataSource.setCompleted(taskId, completed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun deleteTask(taskId: Long) =
|
override suspend fun deleteTask(taskId: Long) =
|
||||||
withContext(io) { dataSource.deleteTask(taskId) }
|
withContext(io) { dataSource.deleteTask(taskId) }
|
||||||
@@ -103,18 +132,32 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
override suspend fun createLocalList(name: String, color: Int): Long =
|
override suspend fun createLocalList(name: String, color: Int): Long =
|
||||||
withContext(io) { dataSource.createLocalList(name, color) }
|
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 {
|
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
|
val provider = providerResolver.resolve() ?: return ProviderStatus.NO_PROVIDER
|
||||||
return if (providerResolver.hasPermission(provider)) ProviderStatus.READY
|
return if (providerResolver.hasPermission(provider)) ProviderStatus.READY
|
||||||
else ProviderStatus.NEEDS_PERMISSION
|
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
|
* callback (main thread) only pokes a conflated channel; the actual blocking
|
||||||
* query runs on [io].
|
* query runs on [io].
|
||||||
*/
|
*/
|
||||||
private fun <T> observing(load: () -> T): Flow<T> = callbackFlow {
|
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 ticks = Channel<Unit>(Channel.CONFLATED)
|
||||||
val handle = dataSource.registerObserver { ticks.trySend(Unit) }
|
val handle = dataSource.registerObserver { ticks.trySend(Unit) }
|
||||||
ticks.trySend(Unit) // prime the initial emission
|
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
|
package de.jeanlucmakiola.agendula.domain
|
||||||
|
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract
|
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/** A task list (the `tasklists` table). Lists group under their account. */
|
/** A task list (the `tasklists` table). Lists group under their account. */
|
||||||
@@ -15,7 +14,8 @@ data class TaskList(
|
|||||||
val owner: String?,
|
val owner: String?,
|
||||||
) {
|
) {
|
||||||
/** A device-only list Agendula (or another app) created locally, not synced. */
|
/** 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 }
|
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 }
|
enum class Priority { NONE, LOW, MEDIUM, HIGH }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A task occurrence as read from the `instances` view. [id] is the instance row
|
* One occurrence of a task. [taskId] is the underlying task row and the stable
|
||||||
* id; [taskId] is the underlying `tasks._id` and the stable target for edits.
|
* target for edits and navigation; [occurrenceStart] distinguishes occurrences of
|
||||||
|
* the same series.
|
||||||
*/
|
*/
|
||||||
data class Task(
|
data class Task(
|
||||||
val id: Long,
|
|
||||||
val taskId: Long,
|
val taskId: Long,
|
||||||
val listId: Long,
|
val listId: Long,
|
||||||
val title: String,
|
val title: String,
|
||||||
@@ -48,7 +48,22 @@ data class Task(
|
|||||||
val listName: String?,
|
val listName: String?,
|
||||||
val accountName: String?,
|
val accountName: String?,
|
||||||
val parentId: Long?,
|
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,
|
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 distanceFromCurrent: Int?,
|
||||||
val created: Instant?,
|
val created: Instant?,
|
||||||
val lastModified: Instant?,
|
val lastModified: Instant?,
|
||||||
@@ -65,6 +80,15 @@ data class Task(
|
|||||||
val isSubtask: Boolean get() = parentId != null && parentId > 0
|
val isSubtask: Boolean get() = parentId != null && parentId > 0
|
||||||
/** The task's own colour if set, else the list colour. */
|
/** The task's own colour if set, else the list colour. */
|
||||||
val effectiveColor: Int get() = taskColor ?: listColor
|
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. */
|
/** 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). */
|
/** Representative iCalendar priority for a bucket (1 high, 5 medium, 9 low). */
|
||||||
fun Priority.toICal(): Int = when (this) {
|
fun Priority.toICal(): Int = when (this) {
|
||||||
Priority.NONE -> TasksContract.PRIORITY_NONE
|
Priority.NONE -> PRIORITY_NONE
|
||||||
Priority.HIGH -> 1
|
Priority.HIGH -> 1
|
||||||
Priority.MEDIUM -> 5
|
Priority.MEDIUM -> 5
|
||||||
Priority.LOW -> 9
|
Priority.LOW -> 9
|
||||||
}
|
}
|
||||||
|
|
||||||
fun statusFromInt(value: Int?): TaskStatus = when (value) {
|
fun statusFromInt(value: Int?): TaskStatus = when (value) {
|
||||||
TasksContract.STATUS_IN_PROCESS -> TaskStatus.IN_PROCESS
|
ICalStatus.IN_PROCESS -> TaskStatus.IN_PROCESS
|
||||||
TasksContract.STATUS_COMPLETED -> TaskStatus.COMPLETED
|
ICalStatus.COMPLETED -> TaskStatus.COMPLETED
|
||||||
TasksContract.STATUS_CANCELLED -> TaskStatus.CANCELLED
|
ICalStatus.CANCELLED -> TaskStatus.CANCELLED
|
||||||
else -> TaskStatus.NEEDS_ACTION
|
else -> TaskStatus.NEEDS_ACTION
|
||||||
}
|
}
|
||||||
|
|
||||||
fun TaskStatus.toInt(): Int = when (this) {
|
fun TaskStatus.toInt(): Int = when (this) {
|
||||||
TaskStatus.NEEDS_ACTION -> TasksContract.STATUS_NEEDS_ACTION
|
TaskStatus.NEEDS_ACTION -> ICalStatus.NEEDS_ACTION
|
||||||
TaskStatus.IN_PROCESS -> TasksContract.STATUS_IN_PROCESS
|
TaskStatus.IN_PROCESS -> ICalStatus.IN_PROCESS
|
||||||
TaskStatus.COMPLETED -> TasksContract.STATUS_COMPLETED
|
TaskStatus.COMPLETED -> ICalStatus.COMPLETED
|
||||||
TaskStatus.CANCELLED -> TasksContract.STATUS_CANCELLED
|
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.Button
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -19,6 +20,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.agendula.R
|
import de.jeanlucmakiola.agendula.R
|
||||||
|
import de.jeanlucmakiola.agendula.ui.common.OnResume
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus
|
||||||
import de.jeanlucmakiola.agendula.ui.navigation.AgendulaNavHost
|
import de.jeanlucmakiola.agendula.ui.navigation.AgendulaNavHost
|
||||||
import de.jeanlucmakiola.agendula.ui.permission.PermissionViewModel
|
import de.jeanlucmakiola.agendula.ui.permission.PermissionViewModel
|
||||||
@@ -39,11 +41,23 @@ fun RootScreen(
|
|||||||
ActivityResultContracts.RequestMultiplePermissions(),
|
ActivityResultContracts.RequestMultiplePermissions(),
|
||||||
) { permissionViewModel.refresh() }
|
) { 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) {
|
when (permission.status) {
|
||||||
ProviderStatus.NO_PROVIDER -> Gate(
|
ProviderStatus.NO_PROVIDER -> Gate(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
title = stringResource(R.string.onboarding_no_provider_title),
|
title = stringResource(R.string.onboarding_no_provider_title),
|
||||||
body = stringResource(R.string.onboarding_no_provider_body),
|
body = stringResource(R.string.onboarding_no_provider_body),
|
||||||
|
secondaryAction = fallback,
|
||||||
|
onSecondaryAction = permissionViewModel::useOwnStore,
|
||||||
)
|
)
|
||||||
ProviderStatus.NEEDS_PERMISSION -> Gate(
|
ProviderStatus.NEEDS_PERMISSION -> Gate(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
@@ -51,6 +65,8 @@ fun RootScreen(
|
|||||||
body = stringResource(R.string.onboarding_permission_body),
|
body = stringResource(R.string.onboarding_permission_body),
|
||||||
action = stringResource(R.string.onboarding_permission_button),
|
action = stringResource(R.string.onboarding_permission_button),
|
||||||
onAction = { launcher.launch(permission.permissionsToRequest.toTypedArray()) },
|
onAction = { launcher.launch(permission.permissionsToRequest.toTypedArray()) },
|
||||||
|
secondaryAction = fallback,
|
||||||
|
onSecondaryAction = permissionViewModel::useOwnStore,
|
||||||
)
|
)
|
||||||
ProviderStatus.READY -> ReadyGate(modifier = modifier)
|
ProviderStatus.READY -> ReadyGate(modifier = modifier)
|
||||||
}
|
}
|
||||||
@@ -87,6 +103,8 @@ private fun Gate(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
action: String? = null,
|
action: String? = null,
|
||||||
onAction: () -> Unit = {},
|
onAction: () -> Unit = {},
|
||||||
|
secondaryAction: String? = null,
|
||||||
|
onSecondaryAction: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier.fillMaxSize().padding(24.dp),
|
modifier = modifier.fillMaxSize().padding(24.dp),
|
||||||
@@ -96,5 +114,6 @@ private fun Gate(
|
|||||||
Text(title, style = MaterialTheme.typography.headlineSmall)
|
Text(title, style = MaterialTheme.typography.headlineSmall)
|
||||||
Text(body, style = MaterialTheme.typography.bodyMedium)
|
Text(body, style = MaterialTheme.typography.bodyMedium)
|
||||||
if (action != null) Button(onClick = onAction) { Text(action) }
|
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. */
|
/** Search — a 6-sided cookie, the same family as [Settings] but distinct. */
|
||||||
val Search: RoundedPolygon get() = MaterialShapes.Cookie6Sided
|
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 androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
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.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskDetail
|
import de.jeanlucmakiola.agendula.domain.TaskDetail
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
@@ -11,7 +12,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
@@ -42,14 +42,14 @@ class TaskDetailViewModel @Inject constructor(
|
|||||||
if (detail == null) TaskDetailUiState.NotFound else TaskDetailUiState.Content(detail)
|
if (detail == null) TaskDetailUiState.NotFound else TaskDetailUiState.Content(detail)
|
||||||
}
|
}
|
||||||
.onStart { emit(TaskDetailUiState.Loading) }
|
.onStart { emit(TaskDetailUiState.Loading) }
|
||||||
.catch { emit(TaskDetailUiState.NotFound) }
|
.recoveringFromProviderFailure { TaskDetailUiState.NotFound }
|
||||||
}
|
}
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskDetailUiState.Loading)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskDetailUiState.Loading)
|
||||||
|
|
||||||
fun bind(id: Long) { taskId.value = id }
|
fun bind(id: Long) { taskId.value = id }
|
||||||
|
|
||||||
fun toggleComplete(task: Task) = viewModelScope.launch {
|
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 {
|
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.agendula.ui.common.localToInstant
|
||||||
import de.jeanlucmakiola.floret.components.pastelize
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
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.common.toLocalTime
|
||||||
import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel
|
import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel
|
||||||
import java.time.LocalTime
|
import java.time.LocalTime
|
||||||
@@ -178,7 +179,7 @@ private fun EditContent(
|
|||||||
val accent = selectedList?.let { pastelize(it.color, dark) } ?: MaterialTheme.colorScheme.primary
|
val accent = selectedList?.let { pastelize(it.color, dark) } ?: MaterialTheme.colorScheme.primary
|
||||||
val gap = 12.dp
|
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 showListPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
var showParentPicker by rememberSaveable { mutableStateOf(false) }
|
var showParentPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
|
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
@@ -689,12 +690,15 @@ private fun DateTimePickerFlow(
|
|||||||
onResult: (Instant) -> Unit,
|
onResult: (Instant) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var pendingDate by remember { mutableStateOf<java.time.LocalDate?>(null) }
|
var pendingDate by rememberSaveable { mutableStateOf<java.time.LocalDate?>(null) }
|
||||||
var showTime by remember { mutableStateOf(false) }
|
var showTime by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
if (!showTime) {
|
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())
|
val initialMillis = (initial ?: nowInstant())
|
||||||
.toLocalDate().atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
.calendarDate(allDay).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||||
val dateState = rememberDatePickerState(initialSelectedDateMillis = initialMillis)
|
val dateState = rememberDatePickerState(initialSelectedDateMillis = initialMillis)
|
||||||
DatePickerDialog(
|
DatePickerDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
@@ -703,7 +707,7 @@ private fun DateTimePickerFlow(
|
|||||||
val millis = dateState.selectedDateMillis ?: run { onDismiss(); return@TextButton }
|
val millis = dateState.selectedDateMillis ?: run { onDismiss(); return@TextButton }
|
||||||
val date = java.time.Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC).toLocalDate()
|
val date = java.time.Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||||
if (allDay) {
|
if (allDay) {
|
||||||
onResult(localToInstant(date, LocalTime.MIDNIGHT))
|
onResult(allDayInstantOf(date))
|
||||||
} else {
|
} else {
|
||||||
pendingDate = date
|
pendingDate = date
|
||||||
showTime = true
|
showTime = true
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import de.jeanlucmakiola.agendula.domain.TaskFormError
|
|||||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
import de.jeanlucmakiola.agendula.domain.populatedFields
|
import de.jeanlucmakiola.agendula.domain.populatedFields
|
||||||
|
import de.jeanlucmakiola.agendula.domain.rebasedForAllDay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -70,6 +71,15 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
|
|
||||||
private var editingTaskId: Long? = null
|
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. */
|
/** `last_modified` captured when the form loaded — the conflict-check baseline. */
|
||||||
private var baselineLastModified: Instant? = null
|
private var baselineLastModified: Instant? = null
|
||||||
|
|
||||||
@@ -78,6 +88,8 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
|
|
||||||
/** Start a fresh task, optionally pre-selecting a list / parent. */
|
/** Start a fresh task, optionally pre-selecting a list / parent. */
|
||||||
fun bindNew(presetListId: Long? = null, parentId: Long? = null) {
|
fun bindNew(presetListId: Long? = null, parentId: Long? = null) {
|
||||||
|
if (bound) return
|
||||||
|
bound = true
|
||||||
editingTaskId = null
|
editingTaskId = null
|
||||||
baselineLastModified = null
|
baselineLastModified = null
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -103,6 +115,8 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
|
|
||||||
/** Load an existing task for editing. */
|
/** Load an existing task for editing. */
|
||||||
fun bindEdit(taskId: Long) {
|
fun bindEdit(taskId: Long) {
|
||||||
|
if (bound && editingTaskId == taskId) return
|
||||||
|
bound = true
|
||||||
editingTaskId = taskId
|
editingTaskId = taskId
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
defaultFields = settingsPrefs.settings.first().defaultEditFields
|
defaultFields = settingsPrefs.settings.first().defaultEditFields
|
||||||
@@ -126,6 +140,7 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
priority = task.priority,
|
priority = task.priority,
|
||||||
parentId = task.parentId,
|
parentId = task.parentId,
|
||||||
percentComplete = task.percentComplete,
|
percentComplete = task.percentComplete,
|
||||||
|
reminderMinutesBeforeDue = repository.reminderFor(taskId),
|
||||||
lists = lists,
|
lists = lists,
|
||||||
parentCandidates = loadParents(task.listId, selfId = taskId),
|
parentCandidates = loadParents(task.listId, selfId = taskId),
|
||||||
),
|
),
|
||||||
@@ -178,7 +193,19 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun onStartChange(value: Instant?) = update { it.copy(start = value) }
|
fun onStartChange(value: Instant?) = update { it.copy(start = value) }
|
||||||
fun onDueChange(value: Instant?) = update { it.copy(due = 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 onPriorityChange(value: Priority) = update { it.copy(priority = value) }
|
||||||
fun onPercentChange(value: Int?) = update { it.copy(percentComplete = value?.coerceIn(0, 100)) }
|
fun onPercentChange(value: Int?) = update { it.copy(percentComplete = value?.coerceIn(0, 100)) }
|
||||||
fun onParentChange(parentId: Long?) = update { it.copy(parentId = parentId) }
|
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.CircularWavyProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
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.domain.TaskFilter
|
||||||
import de.jeanlucmakiola.agendula.ui.common.ActionShapes
|
import de.jeanlucmakiola.agendula.ui.common.ActionShapes
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
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.ListColorChip
|
||||||
import de.jeanlucmakiola.agendula.ui.common.ShapedActionButton
|
import de.jeanlucmakiola.agendula.ui.common.ShapedActionButton
|
||||||
import de.jeanlucmakiola.agendula.ui.common.priorityAccent
|
import de.jeanlucmakiola.agendula.ui.common.priorityAccent
|
||||||
@@ -108,10 +113,22 @@ fun ListsScreen(
|
|||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
var query by rememberSaveable { mutableStateOf("") }
|
var query by rememberSaveable { mutableStateOf("") }
|
||||||
var searchActive by rememberSaveable { mutableStateOf(false) }
|
var searchActive by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var newList by rememberSaveable { mutableStateOf(false) }
|
||||||
val closeSearch = {
|
val closeSearch = {
|
||||||
query = ""
|
query = ""
|
||||||
searchActive = false
|
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.
|
// System back closes search before leaving the screen.
|
||||||
BackHandler(enabled = searchActive, onBack = closeSearch)
|
BackHandler(enabled = searchActive, onBack = closeSearch)
|
||||||
|
|
||||||
@@ -130,9 +147,11 @@ fun ListsScreen(
|
|||||||
// The FAB would otherwise float over the search results.
|
// The FAB would otherwise float over the search results.
|
||||||
if (!searchActive) {
|
if (!searchActive) {
|
||||||
ExtendedFloatingActionButton(
|
ExtendedFloatingActionButton(
|
||||||
onClick = onNewTask,
|
onClick = if (noLists) ({ newList = true }) else onNewTask,
|
||||||
icon = { Icon(Icons.Rounded.Add, contentDescription = null) },
|
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,
|
state = s,
|
||||||
onOpenFilter = onOpenFilter,
|
onOpenFilter = onOpenFilter,
|
||||||
onOpenTask = onOpenTask,
|
onOpenTask = onOpenTask,
|
||||||
|
onNewList = { newList = true },
|
||||||
topPadding = 0.dp,
|
topPadding = 0.dp,
|
||||||
bottomPadding = inner.calculateBottomPadding() + 96.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
|
@Composable
|
||||||
@@ -175,6 +220,7 @@ private fun ListsContent(
|
|||||||
state: ListsUiState.Content,
|
state: ListsUiState.Content,
|
||||||
onOpenFilter: (TaskFilter) -> Unit,
|
onOpenFilter: (TaskFilter) -> Unit,
|
||||||
onOpenTask: (Long) -> Unit,
|
onOpenTask: (Long) -> Unit,
|
||||||
|
onNewList: () -> Unit,
|
||||||
topPadding: androidx.compose.ui.unit.Dp,
|
topPadding: androidx.compose.ui.unit.Dp,
|
||||||
bottomPadding: androidx.compose.ui.unit.Dp,
|
bottomPadding: androidx.compose.ui.unit.Dp,
|
||||||
) {
|
) {
|
||||||
@@ -215,9 +261,21 @@ private fun ListsContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (state.groups.isEmpty()) {
|
if (state.groups.isEmpty()) {
|
||||||
item { CenteredMessage(stringResource(R.string.lists_empty), PaddingValues(top = 24.dp)) }
|
item { EmptyLists(onNewList = onNewList) }
|
||||||
} else {
|
} 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 ->
|
state.groups.forEach { group ->
|
||||||
item(key = "acct-${group.accountName}") { AccountHeader(group.accountName) }
|
item(key = "acct-${group.accountName}") { AccountHeader(group.accountName) }
|
||||||
itemsIndexed(group.lists, key = { _, o -> o.list.id }) { index, overview ->
|
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
|
* 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
|
* this is. Settings is pinned at the right; the search action sits just left of it
|
||||||
@@ -419,7 +499,7 @@ private fun SearchResults(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
items(results, key = { it.id }) { task ->
|
items(results, key = { it.occurrenceKey }) { task ->
|
||||||
UpcomingRow(task = task, onClick = { onOpenTask(task.taskId) })
|
UpcomingRow(task = task, onClick = { onOpenTask(task.taskId) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -655,12 +735,25 @@ private fun SmartCard(count: SmartCount, modifier: Modifier = Modifier, onClick:
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SectionHeader(text: String) {
|
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 = text,
|
text = text,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 4.dp),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
|
action?.invoke()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -4,21 +4,30 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
|
||||||
import de.jeanlucmakiola.floret.time.DayWindow
|
import de.jeanlucmakiola.floret.time.DayWindow
|
||||||
import de.jeanlucmakiola.agendula.domain.SmartList
|
import de.jeanlucmakiola.agendula.domain.SmartList
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFiltering
|
import de.jeanlucmakiola.agendula.domain.TaskFiltering
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.time.Clock
|
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 ListOverview(val list: TaskList, val openCount: Int)
|
||||||
data class AccountGroup(val accountName: String, val lists: List<ListOverview>)
|
data class AccountGroup(val accountName: String, val lists: List<ListOverview>)
|
||||||
data class SmartCount(val smart: SmartList, val count: Int)
|
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. */
|
/** The home overview: smart lists with live counts, then user lists by account. */
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class ListsViewModel @Inject constructor(
|
class ListsViewModel @Inject constructor(
|
||||||
repository: TasksRepository,
|
private val repository: TasksRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
val state: StateFlow<ListsUiState> =
|
val state: StateFlow<ListsUiState> =
|
||||||
@@ -56,7 +65,7 @@ class ListsViewModel @Inject constructor(
|
|||||||
repository.tasks(TaskFilter.Smart(SmartList.COMPLETED)),
|
repository.tasks(TaskFilter.Smart(SmartList.COMPLETED)),
|
||||||
) { lists, openTasks, completedTasks ->
|
) { lists, openTasks, completedTasks ->
|
||||||
buildContent(lists, openTasks, completedTasks) as ListsUiState
|
buildContent(lists, openTasks, completedTasks) as ListsUiState
|
||||||
}.catch { emit(ListsUiState.Failure) }
|
}.recoveringFromProviderFailure { ListsUiState.Failure }
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListsUiState.Loading)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListsUiState.Loading)
|
||||||
|
|
||||||
private fun buildContent(
|
private fun buildContent(
|
||||||
@@ -112,4 +121,22 @@ class ListsViewModel @Inject constructor(
|
|||||||
allTasks = openTasks + completedTasks,
|
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
|
package de.jeanlucmakiola.agendula.ui.permission
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
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.ProviderResolver
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
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.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
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
|
import javax.inject.Inject
|
||||||
|
|
||||||
data class PermissionUiState(
|
data class PermissionUiState(
|
||||||
@@ -20,23 +32,51 @@ data class PermissionUiState(
|
|||||||
* Gates app entry: is a tasks provider installed, and do we hold its permissions?
|
* 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
|
* The Composable owns the actual permission-launcher and store intents; this VM
|
||||||
* supplies the [status] and the exact permission strings to ask for.
|
* 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
|
@HiltViewModel
|
||||||
class PermissionViewModel @Inject constructor(
|
class PermissionViewModel @Inject constructor(
|
||||||
private val repository: TasksRepository,
|
private val repository: TasksRepository,
|
||||||
private val providerResolver: ProviderResolver,
|
private val providerResolver: ProviderResolver,
|
||||||
|
private val prefs: SettingsPrefs,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _state = MutableStateFlow(PermissionUiState())
|
private val refreshes = MutableStateFlow(0)
|
||||||
val state: StateFlow<PermissionUiState> = _state.asStateFlow()
|
|
||||||
|
|
||||||
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). */
|
/** 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()
|
val provider = providerResolver.resolve()
|
||||||
_state.value = PermissionUiState(
|
return PermissionUiState(
|
||||||
status = repository.providerStatus(),
|
status = repository.providerStatus(),
|
||||||
|
// Null in OWN mode, where there is no provider and nothing to grant.
|
||||||
permissionsToRequest = provider
|
permissionsToRequest = provider
|
||||||
?.let { listOf(it.readPermission, it.writePermission) }
|
?.let { listOf(it.readPermission, it.writePermission) }
|
||||||
.orEmpty(),
|
.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.Circle
|
||||||
import androidx.compose.material.icons.rounded.Flag
|
import androidx.compose.material.icons.rounded.Flag
|
||||||
import androidx.compose.material.icons.rounded.Percent
|
import androidx.compose.material.icons.rounded.Percent
|
||||||
|
import androidx.compose.material.icons.rounded.Storage
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -56,7 +57,6 @@ import androidx.compose.material3.Surface
|
|||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -75,13 +75,11 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
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 androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.agendula.R
|
import de.jeanlucmakiola.agendula.R
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||||
|
import de.jeanlucmakiola.agendula.ui.export.ExportScreen
|
||||||
import de.jeanlucmakiola.floret.components.AboutCard
|
import de.jeanlucmakiola.floret.components.AboutCard
|
||||||
import de.jeanlucmakiola.floret.components.AboutLink
|
import de.jeanlucmakiola.floret.components.AboutLink
|
||||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
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.identity.expandEnter
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
|
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
|
||||||
|
import de.jeanlucmakiola.agendula.ui.common.OnResume
|
||||||
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel
|
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel
|
||||||
|
|
||||||
/** The settings sub-screens reached from the hub's category rows. */
|
/** 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),
|
* 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
|
// Inside a sub-screen, system back (button or gesture) returns to the hub
|
||||||
// rather than popping the whole Settings destination to the lists overview.
|
// 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(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -143,6 +153,19 @@ fun SettingsScreen(
|
|||||||
SlideInSection(visible = section == SettingsSection.Reminders) {
|
SlideInSection(visible = section == SettingsSection.Reminders) {
|
||||||
RemindersScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
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) },
|
leading = { CategoryIcon(Icons.Default.Notifications, ChipAccent.Primary) },
|
||||||
onClick = { onOpenSection(SettingsSection.Reminders) },
|
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)
|
LanguageRow(position = Position.Middle)
|
||||||
ReportProblemRow(position = Position.Bottom)
|
ReportProblemRow(position = Position.Bottom)
|
||||||
|
|
||||||
@@ -670,16 +700,11 @@ private fun rememberExactAlarmAllowed(context: Context): Boolean {
|
|||||||
context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms(),
|
context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
OnResume {
|
||||||
DisposableEffect(lifecycle) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
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()
|
allowed = context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lifecycle.addObserver(obs)
|
|
||||||
onDispose { lifecycle.removeObserver(obs) }
|
|
||||||
}
|
|
||||||
return allowed
|
return allowed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,18 +3,27 @@ package de.jeanlucmakiola.agendula.ui.settings
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
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.Settings
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
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.TasksRepository
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -23,6 +32,22 @@ data class SettingsUiState(
|
|||||||
val lists: List<TaskList> = emptyList(),
|
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
|
* Drives both the Settings screen and the app theme (MainActivity collects the
|
||||||
* same instance), so a theme change applies app-wide at once.
|
* same instance), so a theme change applies app-wide at once.
|
||||||
@@ -30,14 +55,48 @@ data class SettingsUiState(
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class SettingsViewModel @Inject constructor(
|
class SettingsViewModel @Inject constructor(
|
||||||
private val prefs: SettingsPrefs,
|
private val prefs: SettingsPrefs,
|
||||||
|
private val resolver: ProviderResolver,
|
||||||
|
private val environment: ProviderEnvironment,
|
||||||
|
@IoDispatcher io: CoroutineDispatcher,
|
||||||
repository: TasksRepository,
|
repository: TasksRepository,
|
||||||
) : ViewModel() {
|
) : 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> =
|
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)
|
SettingsUiState(settings, lists)
|
||||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState())
|
}.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 setThemeMode(mode: ThemeMode) = viewModelScope.launch { prefs.setThemeMode(mode) }
|
||||||
fun setDynamicColor(enabled: Boolean) = viewModelScope.launch { prefs.setDynamicColor(enabled) }
|
fun setDynamicColor(enabled: Boolean) = viewModelScope.launch { prefs.setDynamicColor(enabled) }
|
||||||
fun setDefaultList(id: Long?) = viewModelScope.launch { prefs.setDefaultListId(id) }
|
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.Check
|
||||||
import androidx.compose.material.icons.rounded.Checklist
|
import androidx.compose.material.icons.rounded.Checklist
|
||||||
import androidx.compose.material.icons.rounded.Delete
|
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.ExpandMore
|
||||||
import androidx.compose.material.icons.rounded.Flag
|
import androidx.compose.material.icons.rounded.Flag
|
||||||
import androidx.compose.material3.Checkbox
|
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.TaskSection
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskSections
|
import de.jeanlucmakiola.agendula.domain.TaskSections
|
||||||
import de.jeanlucmakiola.agendula.ui.common.priorityAccent
|
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.Position
|
||||||
import de.jeanlucmakiola.floret.components.SnackChip
|
import de.jeanlucmakiola.floret.components.SnackChip
|
||||||
import de.jeanlucmakiola.floret.components.SnackChipHeight
|
import de.jeanlucmakiola.floret.components.SnackChipHeight
|
||||||
@@ -126,8 +129,22 @@ fun TaskListScreen(
|
|||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
||||||
val content = state as? TaskListUiState.Content
|
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
|
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
|
// 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
|
// bottom quick-add bar; everything else (incl. smart lists, which have no single
|
||||||
// target list) gets the floating "New task" button.
|
// 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,
|
scrollBehavior = scrollBehavior,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -199,6 +226,10 @@ fun TaskListScreen(
|
|||||||
.height(SnackChipHeight),
|
.height(SnackChipHeight),
|
||||||
contentAlignment = Alignment.CenterStart,
|
contentAlignment = Alignment.CenterStart,
|
||||||
) {
|
) {
|
||||||
|
// 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(
|
SnackChip(
|
||||||
visible = undoTarget != null,
|
visible = undoTarget != null,
|
||||||
message = stringResource(R.string.task_deleted),
|
message = stringResource(R.string.task_deleted),
|
||||||
@@ -208,9 +239,30 @@ fun TaskListScreen(
|
|||||||
undoTarget = null
|
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)
|
@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. */
|
/** A visual row in a flattened section run: a top-level task, or one of its subtasks. */
|
||||||
private sealed interface ListRow {
|
private sealed interface ListRow {
|
||||||
val key: Long
|
val key: String
|
||||||
|
|
||||||
data class Parent(val task: Task, val expandable: Boolean, val expanded: Boolean) : ListRow {
|
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 {
|
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. */
|
/** The inline "add a subtask" row that closes an expanded group. */
|
||||||
data class AddSub(val parent: Task) : ListRow {
|
data class AddSub(val parent: Task) : ListRow {
|
||||||
// Negative so it never collides with a real (positive) provider task id.
|
// Prefixed so it never collides with the task row it belongs to.
|
||||||
override val key: Long get() = -parent.taskId
|
override val key: String get() = "add-${parent.occurrenceKey}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,14 +5,17 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
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.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
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.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
@@ -28,13 +31,13 @@ sealed interface TaskListUiState {
|
|||||||
data object Failure : TaskListUiState
|
data object Failure : TaskListUiState
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [listName] is the real list's name when the filter is a
|
* [list] is the real list when the filter is a [TaskFilter.OfList] — it
|
||||||
* [TaskFilter.OfList] (for the top-bar title), `null` for smart lists —
|
* titles the bar and backs the edit action — and `null` for smart lists,
|
||||||
* the screen falls back to the smart label in that case.
|
* where the screen falls back to the smart label.
|
||||||
*/
|
*/
|
||||||
data class Content(
|
data class Content(
|
||||||
val tasks: List<Task>,
|
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). */
|
/** Whether the inline "add a subtask" row shows on expanded groups (M5 setting). */
|
||||||
val showAddSubtaskRow: Boolean = true,
|
val showAddSubtaskRow: Boolean = true,
|
||||||
/** Whether a real list uses the bottom quick-add bar instead of the FAB. */
|
/** 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 tasks = repository.tasks(f)
|
||||||
val content: kotlinx.coroutines.flow.Flow<TaskListUiState> = when (f) {
|
val content: kotlinx.coroutines.flow.Flow<TaskListUiState> = when (f) {
|
||||||
is TaskFilter.OfList ->
|
is TaskFilter.OfList ->
|
||||||
combine(tasks, repository.taskLists()) { list, lists ->
|
combine(tasks, repository.taskLists()) { rows, lists ->
|
||||||
TaskListUiState.Content(list, lists.firstOrNull { it.id == f.listId }?.name)
|
TaskListUiState.Content(rows, lists.firstOrNull { it.id == f.listId })
|
||||||
}
|
}
|
||||||
is TaskFilter.Smart ->
|
is TaskFilter.Smart ->
|
||||||
tasks.map { TaskListUiState.Content(it) }
|
tasks.map { TaskListUiState.Content(it) }
|
||||||
@@ -87,7 +90,10 @@ class TaskListViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onStart { emit(TaskListUiState.Loading) }
|
.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)
|
.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() }
|
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())
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
|
||||||
|
|
||||||
/** The screen reports which expanded parents need their children fetched. */
|
/** 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 bind(taskFilter: TaskFilter) { filter.value = taskFilter }
|
||||||
|
|
||||||
fun toggleComplete(task: Task) = viewModelScope.launch {
|
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. */
|
/** 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)) }
|
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]. */
|
/** Inline "add subtask" from an expanded list group — files it under [parent]. */
|
||||||
fun quickAddSubtask(parent: Task, title: String) = viewModelScope.launch {
|
fun quickAddSubtask(parent: Task, title: String) = viewModelScope.launch {
|
||||||
if (title.isBlank() || parent.listId <= 0L) return@launch
|
if (title.isBlank() || parent.listId <= 0L) return@launch
|
||||||
|
|||||||
@@ -1,190 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<string name="back">Zurück</string>
|
|
||||||
<string name="save">Speichern</string>
|
|
||||||
<string name="delete">Löschen</string>
|
|
||||||
<string name="edit">Bearbeiten</string>
|
|
||||||
<string name="field_description">Beschreibung</string>
|
|
||||||
<string name="task_deleted">Gelöscht</string>
|
|
||||||
<string name="app_name">Agendula</string>
|
|
||||||
<string name="task_untitled">Unbenannte Aufgabe</string>
|
|
||||||
<string name="tasks_title">Aufgaben</string>
|
|
||||||
<string name="task_list_empty">Keine Aufgaben bisher.</string>
|
|
||||||
<string name="task_list_failure">Aufgaben konnten nicht geladen werden.</string>
|
|
||||||
<string name="smart_no_date">Kein Datum</string>
|
|
||||||
<string name="smart_completed">Abgeschlossen</string>
|
|
||||||
<string name="task_detail_title">Aufgabe</string>
|
|
||||||
<string name="task_detail_not_found">Diese Aufgabe ist nicht mehr verfügbar.</string>
|
|
||||||
<string name="edit_task_title">Aufgabe bearbeiten</string>
|
|
||||||
<string name="new_task_title">Neue Aufgabe</string>
|
|
||||||
<string name="field_title">Titel</string>
|
|
||||||
<string name="edit_save_failed">Konnte nicht gespeichert werden. Versuche es erneut.</string>
|
|
||||||
<string name="add_task_hint">Aufgabe hinzufügen</string>
|
|
||||||
<string name="add_subtask_hint">Unteraufgabe hinzufügen</string>
|
|
||||||
<string name="cd_add_task">Aufgabe hinzufügen</string>
|
|
||||||
<string name="cd_complete">Abgeschlossen</string>
|
|
||||||
<string name="cd_reopen">Erneut öffnen</string>
|
|
||||||
<string name="undo">Rückgängig</string>
|
|
||||||
<string name="section_today">Heute</string>
|
|
||||||
<string name="cd_expand_subtasks">Unteraufgaben anzeigen</string>
|
|
||||||
<string name="cd_collapse_subtasks">Unteraufgaben ausblenden</string>
|
|
||||||
<string name="section_overdue">Überfällig</string>
|
|
||||||
<string name="section_upcoming">Anstehend</string>
|
|
||||||
<string name="section_no_date">Kein Datum</string>
|
|
||||||
<string name="section_completed">Abgeschlossen</string>
|
|
||||||
<string name="priority_none">Keine</string>
|
|
||||||
<string name="priority_low">Niedrig</string>
|
|
||||||
<string name="priority_medium">Mittel</string>
|
|
||||||
<string name="priority_high">Hoch</string>
|
|
||||||
<string name="detail_mark_complete">Als abgeschlossen markieren</string>
|
|
||||||
<string name="detail_mark_incomplete">Als nicht abgeschlossen markieren</string>
|
|
||||||
<string name="detail_list">Liste</string>
|
|
||||||
<string name="detail_due">Fällig</string>
|
|
||||||
<string name="detail_start">Beginn</string>
|
|
||||||
<string name="detail_priority">Priorität</string>
|
|
||||||
<string name="detail_progress">Fortschritt</string>
|
|
||||||
<string name="detail_subtasks">Unteraufgaben</string>
|
|
||||||
<string name="detail_parent">Hauptaufgabe</string>
|
|
||||||
<string name="detail_part_of">Teil von</string>
|
|
||||||
<string name="edit_list_label">Liste</string>
|
|
||||||
<string name="edit_start_label">Beginn</string>
|
|
||||||
<string name="edit_due_label">Fällig</string>
|
|
||||||
<string name="edit_all_day">Ganzer Tag</string>
|
|
||||||
<string name="edit_priority_label">Priorität</string>
|
|
||||||
<string name="edit_progress_label">Fortschritt</string>
|
|
||||||
<string name="edit_parent_label">Hauptaufgabe</string>
|
|
||||||
<string name="edit_parent_none">Keine (Hauptebene)</string>
|
|
||||||
<string name="edit_parent_search">Aufgaben suchen</string>
|
|
||||||
<string name="edit_parent_empty">Keine passenden Aufgaben in der Liste gefunden.</string>
|
|
||||||
<string name="edit_reminder_label">Erinnerungen</string>
|
|
||||||
<string name="edit_set">Übernehmen</string>
|
|
||||||
<string name="edit_clear">Leeren</string>
|
|
||||||
<string name="edit_title_hint">Aufgaben-Titel</string>
|
|
||||||
<string name="edit_when">Wann</string>
|
|
||||||
<string name="edit_more_fields">Weitere Felder</string>
|
|
||||||
<string name="close">Schließen</string>
|
|
||||||
<string name="dialog_cancel">Abbrechen</string>
|
|
||||||
<string name="edit_conflict_title">Termin wurde extern geändert</string>
|
|
||||||
<string name="edit_conflict_body">Die Aufgabe wurde seit der Öffnung wo anders geändert (über Sync oder einer anderen App). Möchtest die Änderungen überschreiben?</string>
|
|
||||||
<string name="edit_conflict_overwrite">Überschreiben</string>
|
|
||||||
<string name="reminder_none">Keine</string>
|
|
||||||
<string name="reminder_at_due">Zum Start der Aufgabe</string>
|
|
||||||
<string name="reminder_5_min">5 Minuten vorher</string>
|
|
||||||
<string name="reminder_10_min">10 Minuten vorher</string>
|
|
||||||
<string name="reminder_30_min">30 Minuten vorher</string>
|
|
||||||
<string name="reminder_1_hour">1 Stunde vorher</string>
|
|
||||||
<string name="reminder_1_day">1 Tag vorher</string>
|
|
||||||
<string name="reminder_custom">Benutzerdefiniert</string>
|
|
||||||
<string name="reminder_custom_with_value">Benutzerdefiniert (%1$s)</string>
|
|
||||||
<string name="reminder_custom_set">Übernehmen</string>
|
|
||||||
<string name="reminder_custom_amount">Anzahl eingeben</string>
|
|
||||||
<string name="reminder_use_default">Standard verwenden</string>
|
|
||||||
<string name="reminder_unit_minutes">Minuten</string>
|
|
||||||
<string name="reminder_unit_hours">Stunden</string>
|
|
||||||
<string name="reminder_unit_days">Tage</string>
|
|
||||||
<string name="reminder_unit_weeks">Woche</string>
|
|
||||||
<string name="settings_list_reminders_title">Erinnerungen (Pro Liste)</string>
|
|
||||||
<string name="settings_list_reminders_hint">Überschreibe die Standard-Erinnerungen für individuelle Listen</string>
|
|
||||||
<string name="settings_list_reminder_inherits">Standard (%1$s)</string>
|
|
||||||
<string name="error_blank_title">Gebe einen Titel ein.</string>
|
|
||||||
<string name="error_no_list">Wähle eine Liste.</string>
|
|
||||||
<string name="error_due_before_start">Fälligkeit darf nicht vor dem Start liegen.</string>
|
|
||||||
<string name="error_reminder_without_due">Setze ein Fälligkeits-Datum für Erinnerungen.</string>
|
|
||||||
<string name="lists_header">Listen</string>
|
|
||||||
<string name="new_task">Neue Aufgabe</string>
|
|
||||||
<string name="lists_failure">Deine Aufgaben konnten nicht gelesen werden.</string>
|
|
||||||
<string name="lists_empty">Keine Aufgaben-Listen bisher. Füge eine in deiner Aufgaben-App hinzu oder mit der + Schaltfläche.</string>
|
|
||||||
<string name="smart_today">Heute</string>
|
|
||||||
<string name="smart_overdue">Überfällig</string>
|
|
||||||
<string name="smart_upcoming">Anstehend</string>
|
|
||||||
<string name="smart_all">Alle</string>
|
|
||||||
<string name="open_count">%1$d offen</string>
|
|
||||||
<string name="home_today_progress">%1$d von %2$d erledigt</string>
|
|
||||||
<string name="home_today_remaining">%1$d übrig</string>
|
|
||||||
<string name="home_today_all_done">Alles erledigt 🎉</string>
|
|
||||||
<string name="home_today_empty">Heute keine Fälligkeiten 🎉</string>
|
|
||||||
<string name="home_upcoming_view_all">Alle anzeigen</string>
|
|
||||||
<string name="home_due_today">Heute</string>
|
|
||||||
<string name="home_due_tomorrow">Morgen</string>
|
|
||||||
<string name="home_search_hint">Aufgaben suchen</string>
|
|
||||||
<string name="home_search_clear">Suche zurücksetzen</string>
|
|
||||||
<string name="home_search_close">Suche schließen</string>
|
|
||||||
<string name="home_search_empty">Keine Aufgaben passen zu „%1$s“</string>
|
|
||||||
<string name="reminder_due_at">Fälligkeiten %1$s</string>
|
|
||||||
<string name="reminder_channel_name">Aufgaben Erinnerungen</string>
|
|
||||||
<string name="reminder_channel_desc">Benachrichtigungen für fällige Aufgaben.</string>
|
|
||||||
<string name="onboarding_no_provider_title">Eine Task-App wird benötigt</string>
|
|
||||||
<string name="onboarding_no_provider_body">Agendula zeigt und bearbeitet die Aufgaben, die in OpenTasks oder tasks.org gespeichert sind und per DAVx5 synchronisiert werden. Installiere eine davon, um zu starten.</string>
|
|
||||||
<string name="onboarding_permission_title">Erlaube Zugriff auf deine Aufgaben</string>
|
|
||||||
<string name="onboarding_permission_body">Agendula benötigt die Berechtigung, deine Aufgaben zu lesen und zu schreiben. Das ist die einzige Berechtigung, die es jemals anfragt.</string>
|
|
||||||
<string name="onboarding_permission_button">Zugriff gewähren</string>
|
|
||||||
<string name="onboarding_install_opentasks">Installiere OpenTasks</string>
|
|
||||||
<string name="onboarding_install_tasksorg">Installiere tasks.org</string>
|
|
||||||
<string name="reminder_onboarding_title">Verpasse nichts, was fällig ist</string>
|
|
||||||
<string name="reminder_onboarding_body">Aufgaben-Apps senden selbst keine Erinnerungen, daher liefert Agendula sie für dich. Schalte sie ein, um eine Benachrichtigung zu erhalten, wenn eine Aufgabe fällig ist.</string>
|
|
||||||
<string name="reminder_onboarding_enable_button">Erinnerungen aktivieren</string>
|
|
||||||
<string name="reminder_onboarding_skip_button">Nicht jetzt</string>
|
|
||||||
<string name="reminder_benefit_delivery_title">Agendula erinnert dich</string>
|
|
||||||
<string name="reminder_benefit_delivery_body">Es plant eine Benachrichtigung für jede Aufgabe mit einem Fälligkeitsdatum.</string>
|
|
||||||
<string name="reminder_benefit_timing_title">Auf deinem Zeitplan</string>
|
|
||||||
<string name="reminder_benefit_timing_body">Wähle in den Einstellungen, wie weit im Voraus du erinnert werden möchtest.</string>
|
|
||||||
<string name="reminder_benefit_reversible_title">Jederzeit änderbar</string>
|
|
||||||
<string name="reminder_benefit_reversible_body">Schalte Erinnerungen aus, wann immer du willst — es ist nur ein Schalter.</string>
|
|
||||||
<string name="settings_title">Einstellungen</string>
|
|
||||||
<string name="settings_section_appearance">Darstellung</string>
|
|
||||||
<string name="settings_appearance_subtitle">Theme und Farbe</string>
|
|
||||||
<string name="settings_section_task_form">Aufgaben-Formular</string>
|
|
||||||
<string name="settings_task_form_subtitle">Standard Felder, Liste und Unter-Aufgaben</string>
|
|
||||||
<string name="settings_reminders_subtitle">Benachrichtigungen für fällige Aufgaben</string>
|
|
||||||
<string name="settings_form_fields_hint">Standardmäßig angezeigte Felder — alles Weitere liegt hinter „Weitere Felder“.</string>
|
|
||||||
<string name="settings_dynamic_color_unavailable">Verfügbar für Android 12 und höher</string>
|
|
||||||
<string name="settings_about_author">von Jean-Luc Makiola</string>
|
|
||||||
<string name="settings_about_source">Quellcode</string>
|
|
||||||
<string name="settings_license">Lizenz</string>
|
|
||||||
<string name="settings_about_support">Entwicklung unterstützen</string>
|
|
||||||
<string name="settings_about_version">Version %1$s</string>
|
|
||||||
<string name="settings_about_logo_desc">Calendula-App-Symbol</string>
|
|
||||||
<string name="settings_language">App-Sprache</string>
|
|
||||||
<string name="settings_language_auto">Systemstandard</string>
|
|
||||||
<string name="settings_report_problem">Problem melden</string>
|
|
||||||
<string name="settings_report_problem_hint">Öffne den Issue-Tracker</string>
|
|
||||||
<string name="crash_report_issue_title">Absturzbericht</string>
|
|
||||||
<string name="settings_theme">Design</string>
|
|
||||||
<string name="settings_theme_system">System folgen</string>
|
|
||||||
<string name="settings_theme_light">Hell</string>
|
|
||||||
<string name="settings_theme_dark">Dunkel</string>
|
|
||||||
<string name="settings_dynamic_color">Dynamische Farben</string>
|
|
||||||
<string name="settings_dynamic_color_hint">Verwende Farben aus dem Hintergrundbild</string>
|
|
||||||
<string name="settings_section_reminders">Erinnerungen</string>
|
|
||||||
<string name="settings_reminders">Fälligkeits-Erinnerungen</string>
|
|
||||||
<string name="settings_reminders_hint">Benachrichtige mich wenn eine Aufgabe fällig ist</string>
|
|
||||||
<string name="settings_default_reminder">Wann erinnern</string>
|
|
||||||
<string name="settings_exact_alarms">Exakter Zeitpunkt</string>
|
|
||||||
<string name="settings_exact_alarms_allowed">Die Erinnerungen erfolgen zum exakten Zeitpunkt</string>
|
|
||||||
<string name="settings_exact_alarms_blocked">Blockiert – tippe, um exakte Erinnerungen zu erlauben</string>
|
|
||||||
<string name="settings_section_tasks">Aufgaben</string>
|
|
||||||
<string name="settings_default_list">Standard-Liste</string>
|
|
||||||
<string name="settings_default_list_first">Erste verfügbare Felder</string>
|
|
||||||
<string name="settings_add_subtask_row">Zeile \"Unteraufgaben hinzufügen\" anzeigen</string>
|
|
||||||
<string name="settings_add_subtask_row_hint">Ein Eingabefeld am Ende der Unteraufgaben einer aufgeklappten Aufgabe</string>
|
|
||||||
<string name="settings_bottom_add_bar">Untere Schnellhinzufüge-Leiste</string>
|
|
||||||
<string name="settings_bottom_add_bar_hint">Füge Aufgaben hinzu, über eine Leiste, die unten an einer Liste angepinnt ist, statt über die schwebende Schaltfläche</string>
|
|
||||||
<plurals name="reminder_minutes">
|
|
||||||
<item quantity="one">%1$d Minute vorher</item>
|
|
||||||
<item quantity="other">%1$d Minuten vorher</item>
|
|
||||||
</plurals>
|
|
||||||
<plurals name="reminder_hours">
|
|
||||||
<item quantity="one">%1$d Stunde vorher</item>
|
|
||||||
<item quantity="other">%1$d Stunden vorher</item>
|
|
||||||
</plurals>
|
|
||||||
<plurals name="reminder_days">
|
|
||||||
<item quantity="one">%1$d Tag vorher</item>
|
|
||||||
<item quantity="other">%1$d Tage vorher</item>
|
|
||||||
</plurals>
|
|
||||||
<plurals name="reminder_weeks">
|
|
||||||
<item quantity="one">%1$d Woche vorher</item>
|
|
||||||
<item quantity="other">%1$d Wochen vorher</item>
|
|
||||||
</plurals>
|
|
||||||
<string name="settings_translate">Hilf beim Übersetzen</string>
|
|
||||||
<string name="settings_translate_hint">Füge eine Sprache auf Weblate hinzu oder verbessere sie</string>
|
|
||||||
</resources>
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<string name="app_name">Agendula</string>
|
|
||||||
<string name="app_tagline">Um app de tarefas em Material 3 Expressive. \nFlorescendo.</string>
|
|
||||||
<string name="task_untitled">Tarefa sem título</string>
|
|
||||||
<string name="back">Voltar</string>
|
|
||||||
<string name="save">Salvar</string>
|
|
||||||
<string name="delete">Deletar</string>
|
|
||||||
<string name="tasks_title">Tarefas</string>
|
|
||||||
<string name="task_list_empty">Nenhuma tarefa aqui ainda.</string>
|
|
||||||
<string name="task_list_failure">Não foi possível carregar estas tarefas.</string>
|
|
||||||
<string name="smart_no_date">Sem data</string>
|
|
||||||
<string name="smart_completed">Completo</string>
|
|
||||||
<string name="task_detail_title">Tarefa</string>
|
|
||||||
<string name="task_detail_not_found">Esta tarefa não existe mais.</string>
|
|
||||||
<string name="edit">Editar</string>
|
|
||||||
<string name="edit_task_title">Editar tarefa</string>
|
|
||||||
<string name="new_task_title">Nova tarefa</string>
|
|
||||||
<string name="field_title">Título</string>
|
|
||||||
<string name="field_description">Descrição</string>
|
|
||||||
<string name="edit_save_failed">Não foi possível salvar. Tente novamente.</string>
|
|
||||||
<string name="add_task_hint">Adicionar tarefa</string>
|
|
||||||
<string name="add_subtask_hint">Adicionar subtarefa</string>
|
|
||||||
<string name="cd_add_task">Adicionar tarefa</string>
|
|
||||||
<string name="cd_complete">Concluído</string>
|
|
||||||
<string name="cd_reopen">Reabrir</string>
|
|
||||||
<string name="task_deleted">Deletado</string>
|
|
||||||
<string name="undo">Desfazer</string>
|
|
||||||
<string name="cd_expand_subtasks">Mostrar subtarefas</string>
|
|
||||||
<string name="cd_collapse_subtasks">Esconder subtarefas</string>
|
|
||||||
<string name="section_overdue">Prazo expirado</string>
|
|
||||||
<string name="section_today">Hoje</string>
|
|
||||||
<string name="section_upcoming">A seguir</string>
|
|
||||||
<string name="section_no_date">Sem data</string>
|
|
||||||
<string name="section_completed">Concluído</string>
|
|
||||||
<string name="priority_none">Nenhuma</string>
|
|
||||||
<string name="priority_low">Baixa</string>
|
|
||||||
<string name="priority_medium">Média</string>
|
|
||||||
<string name="priority_high">Alta</string>
|
|
||||||
<string name="detail_mark_complete">Marcar como concluído</string>
|
|
||||||
<string name="detail_mark_incomplete">Marcar como não concluído</string>
|
|
||||||
<string name="detail_list">Lista</string>
|
|
||||||
<string name="detail_due">Prazo</string>
|
|
||||||
<string name="detail_start">Inicia</string>
|
|
||||||
<string name="detail_priority">Prioridade</string>
|
|
||||||
<string name="detail_progress">Progresso</string>
|
|
||||||
<string name="detail_subtasks">Subtarefas</string>
|
|
||||||
<string name="detail_parent">Tarefa pai</string>
|
|
||||||
<string name="detail_part_of">Parte de</string>
|
|
||||||
<string name="percent_complete">%1$d%%</string>
|
|
||||||
<string name="edit_list_label">Lista</string>
|
|
||||||
<string name="edit_start_label">Inicia</string>
|
|
||||||
<string name="edit_due_label">Prazo</string>
|
|
||||||
<string name="edit_all_day">O dia todo</string>
|
|
||||||
<string name="edit_priority_label">Prioridade</string>
|
|
||||||
<string name="edit_progress_label">Progresso</string>
|
|
||||||
<string name="edit_parent_label">Tarefa pai</string>
|
|
||||||
<string name="edit_parent_none">Nenhum (primeiro nível)</string>
|
|
||||||
<string name="edit_parent_search">Buscar tarefas</string>
|
|
||||||
<string name="edit_parent_empty">Nenhuma tarefa correspondente nesta lista.</string>
|
|
||||||
<string name="edit_reminder_label">Lembrete</string>
|
|
||||||
<string name="edit_set">Ajustar</string>
|
|
||||||
<string name="edit_clear">Limpar</string>
|
|
||||||
<string name="edit_title_hint">Título da tarefa</string>
|
|
||||||
<string name="edit_when">Quando</string>
|
|
||||||
<string name="edit_more_fields">Mais campos</string>
|
|
||||||
<string name="close">Fechar</string>
|
|
||||||
<string name="dialog_cancel">Cancelar</string>
|
|
||||||
<string name="edit_conflict_title">A tarefa foi alterada em outro lugar</string>
|
|
||||||
<string name="edit_conflict_body">Esta tarefa foi atualizada em outro local (uma sincronização ou outro app) desde que você a abriu. Sobrescrever estas mudanças com suas edições?</string>
|
|
||||||
<string name="edit_conflict_overwrite">Sobrescrever</string>
|
|
||||||
<string name="reminder_none">Nenhum</string>
|
|
||||||
<string name="reminder_at_due">Na hora da tarefa</string>
|
|
||||||
<string name="reminder_5_min">5 minutos antes</string>
|
|
||||||
<string name="reminder_10_min">10 minutos antes</string>
|
|
||||||
<string name="reminder_30_min">30 minutos antes</string>
|
|
||||||
<string name="reminder_1_hour">1 hora antes</string>
|
|
||||||
<string name="reminder_1_day">1 dia antes</string>
|
|
||||||
<string name="reminder_custom">Personalizado</string>
|
|
||||||
<string name="reminder_custom_with_value">Personalizado (%1$s)</string>
|
|
||||||
<string name="reminder_custom_set">Ajustar</string>
|
|
||||||
<string name="reminder_custom_amount">Insira uma quantidade</string>
|
|
||||||
<string name="reminder_use_default">Usar padrão</string>
|
|
||||||
<string name="reminder_unit_minutes">Minutos</string>
|
|
||||||
<string name="reminder_unit_hours">Horas</string>
|
|
||||||
<string name="reminder_unit_days">Dias</string>
|
|
||||||
<string name="reminder_unit_weeks">Semanas</string>
|
|
||||||
<string name="settings_list_reminders_title">Lembretes por lista</string>
|
|
||||||
<string name="settings_list_reminders_hint">Alterar o lembrete padrão para listas individuais</string>
|
|
||||||
<string name="settings_list_reminder_inherits">Padrão (%1$s)</string>
|
|
||||||
<string name="error_blank_title">Insira um título.</string>
|
|
||||||
<string name="error_no_list">Escolha uma lista.</string>
|
|
||||||
<string name="error_due_before_start">Prazo não pode ser antes do início.</string>
|
|
||||||
<string name="error_reminder_without_due">Ajuste um prazo para usar um lembrete.</string>
|
|
||||||
<string name="lists_header">Listas</string>
|
|
||||||
<string name="new_task">Nova tarefa</string>
|
|
||||||
<string name="lists_failure">Não foi possível ler suas tarefas.</string>
|
|
||||||
<string name="lists_empty">Nenhuma lista de tarefas ainda. Adicione uma no seu app de tarefas ou com o botão +.</string>
|
|
||||||
<string name="smart_today">Hoje</string>
|
|
||||||
<string name="smart_overdue">Prazo expirado</string>
|
|
||||||
<string name="smart_upcoming">A seguir</string>
|
|
||||||
<string name="smart_all">Tudo</string>
|
|
||||||
<string name="open_count">%1$d abertos</string>
|
|
||||||
<string name="home_today_progress">%1$d de %2$d concluídos</string>
|
|
||||||
<string name="home_today_remaining">%1$d restantes</string>
|
|
||||||
<string name="home_today_all_done">Tudo pronto 🎉</string>
|
|
||||||
<string name="home_today_empty">Nada terminando hoje 🎉</string>
|
|
||||||
<string name="home_upcoming_view_all">Ver tudo</string>
|
|
||||||
<string name="home_due_today">Hoje</string>
|
|
||||||
<string name="home_due_tomorrow">Amanhã</string>
|
|
||||||
<string name="home_search_hint">Buscar tarefas</string>
|
|
||||||
<string name="home_search_clear">Limpar buscas</string>
|
|
||||||
<string name="home_search_close">Fechar busca</string>
|
|
||||||
<string name="home_search_empty">Nenhuma tarefa corresponde a \"%1$s\"</string>
|
|
||||||
<string name="reminder_due_at">Termina em %1$s</string>
|
|
||||||
<string name="reminder_channel_name">Lembretes de tarefas</string>
|
|
||||||
<string name="reminder_channel_desc">Notificações para tarefas com prazo.</string>
|
|
||||||
<string name="onboarding_no_provider_title">Um app de tarefas é necessário</string>
|
|
||||||
<string name="onboarding_no_provider_body">O Agendula mostra e edita as tarefas armazenadas no OpenTasks ou tasks.org, sincronizadas pelo DAVx5. Instale um para começar.</string>
|
|
||||||
<string name="onboarding_permission_title">Permitir acesso às suas tarefas</string>
|
|
||||||
<string name="onboarding_permission_body">O Agendula precisa de permissão para ler e salvar suas tarefas. É tudo o que ele pede.</string>
|
|
||||||
<string name="onboarding_permission_button">Conceder acesso às tarefas</string>
|
|
||||||
<string name="onboarding_install_opentasks">Instalar OpenTasks</string>
|
|
||||||
<string name="onboarding_install_tasksorg">Instalar tasks.org</string>
|
|
||||||
<string name="reminder_onboarding_title">Nunca perca seus prazos</string>
|
|
||||||
<string name="reminder_onboarding_body">Apps de tarefas não enviam lembretes sozinhos, então o Agendula os entrega para você. Ative para receber uma notificação de quando uma tarefa estiver no prazo.</string>
|
|
||||||
<string name="reminder_onboarding_enable_button">Ativar lembretes</string>
|
|
||||||
<string name="reminder_onboarding_skip_button">Agora não</string>
|
|
||||||
<string name="reminder_benefit_delivery_title">O Agendula lembra você</string>
|
|
||||||
<string name="reminder_benefit_delivery_body">Ele agenda uma notificação para cada tarefa com prazo.</string>
|
|
||||||
<string name="reminder_benefit_timing_title">Na sua rotina</string>
|
|
||||||
<string name="reminder_benefit_timing_body">Escolha o quão à frente quer ser lembrado em Ajustes.</string>
|
|
||||||
<string name="reminder_benefit_reversible_title">Altere a qualquer momento</string>
|
|
||||||
<string name="reminder_benefit_reversible_body">Desative os lembretes quando quiser — é só um botão.</string>
|
|
||||||
<string name="settings_title">Ajustes</string>
|
|
||||||
<string name="settings_section_appearance">Aparência</string>
|
|
||||||
<string name="settings_appearance_subtitle">Tema e cor</string>
|
|
||||||
<string name="settings_section_task_form">Formulário de tarefa</string>
|
|
||||||
<string name="settings_task_form_subtitle">Campos, listas e subtarefas padrão</string>
|
|
||||||
<string name="settings_reminders_subtitle">Notificações para tarefas com prazo acabando</string>
|
|
||||||
<string name="settings_form_fields_hint">Campos mostrados por padrão em uma nova tarefa. O resto está atrás de \"Mais campos\".</string>
|
|
||||||
<string name="settings_dynamic_color_unavailable">Disponível no Android 12 e mais recente</string>
|
|
||||||
<string name="settings_about_author">por Jean-Luc Makiola</string>
|
|
||||||
<string name="settings_about_source">Código-fonte</string>
|
|
||||||
<string name="settings_license">Licença</string>
|
|
||||||
<string name="settings_about_support">Apoie o desenvolvimento</string>
|
|
||||||
<string name="settings_about_version">Versão %1$s</string>
|
|
||||||
<string name="settings_about_logo_desc">Ícone de app do Agendula</string>
|
|
||||||
<string name="settings_language">Idioma do app</string>
|
|
||||||
<string name="settings_language_auto">Padrão do sistema</string>
|
|
||||||
<string name="settings_translate">Ajude a traduzir</string>
|
|
||||||
<string name="settings_translate_hint">Adicione ou melhore um idioma no Weblate</string>
|
|
||||||
<string name="settings_report_problem">Relate um problema</string>
|
|
||||||
<string name="settings_report_problem_hint">Abrir o rastreador de problemas</string>
|
|
||||||
<string name="crash_report_issue_title">Relatório de quebra</string>
|
|
||||||
<string name="settings_theme">Tema</string>
|
|
||||||
<string name="settings_theme_system">Seguir o sistema</string>
|
|
||||||
<string name="settings_theme_light">Claro</string>
|
|
||||||
<string name="settings_theme_dark">Escuro</string>
|
|
||||||
<string name="settings_dynamic_color">Cor dinâmica</string>
|
|
||||||
<string name="settings_dynamic_color_hint">Usar cores do papel de parede</string>
|
|
||||||
<string name="settings_section_reminders">Lembretes</string>
|
|
||||||
<string name="settings_reminders">Lembretes no prazo</string>
|
|
||||||
<string name="settings_reminders_hint">Me notifique quando uma tarefa estiver no prazo</string>
|
|
||||||
<string name="settings_default_reminder">Quando lembrar</string>
|
|
||||||
<string name="settings_exact_alarms">Na hora</string>
|
|
||||||
<string name="settings_exact_alarms_allowed">Lembretes disparam na mesma hora</string>
|
|
||||||
<string name="settings_exact_alarms_blocked">Bloqueado — toque para permitir lembretes na hora</string>
|
|
||||||
<string name="settings_section_tasks">Tarefas</string>
|
|
||||||
<string name="settings_default_list">Lista padrão</string>
|
|
||||||
<string name="settings_default_list_first">Primeira lista disponível</string>
|
|
||||||
<string name="settings_add_subtask_row">Mostrar uma linha \"adicionar subtarefa\"</string>
|
|
||||||
<string name="settings_add_subtask_row_hint">Um campo Adicionar no fim das subtarefas de uma tarefa expandida</string>
|
|
||||||
<string name="settings_bottom_add_bar">Barra adicionar-rápido abaixo</string>
|
|
||||||
<string name="settings_bottom_add_bar_hint">Adicione tarefas de uma barra presa abaixo de uma lista, em vez do botão flutuante</string>
|
|
||||||
<plurals name="reminder_minutes">
|
|
||||||
<item quantity="one">%1$d minuto antes</item>
|
|
||||||
<item quantity="many">%1$d minutos antes</item>
|
|
||||||
<item quantity="other">%1$d minutos antes</item>
|
|
||||||
</plurals>
|
|
||||||
<plurals name="reminder_hours">
|
|
||||||
<item quantity="one">%1$d hora antes</item>
|
|
||||||
<item quantity="many">%1$d horas antes</item>
|
|
||||||
<item quantity="other">%1$d horas antes</item>
|
|
||||||
</plurals>
|
|
||||||
<plurals name="reminder_days">
|
|
||||||
<item quantity="one">%1$d dia antes</item>
|
|
||||||
<item quantity="many">%1$d dias antes</item>
|
|
||||||
<item quantity="other">%1$d dias antes</item>
|
|
||||||
</plurals>
|
|
||||||
<plurals name="reminder_weeks">
|
|
||||||
<item quantity="one">%1$d semana antes</item>
|
|
||||||
<item quantity="many">%1$d semanas antes</item>
|
|
||||||
<item quantity="other">%1$d semanas antes</item>
|
|
||||||
</plurals>
|
|
||||||
</resources>
|
|
||||||
@@ -122,7 +122,32 @@
|
|||||||
<string name="lists_header">Lists</string>
|
<string name="lists_header">Lists</string>
|
||||||
<string name="new_task">New task</string>
|
<string name="new_task">New task</string>
|
||||||
<string name="lists_failure">Could not read your tasks.</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_today">Today</string>
|
||||||
<string name="smart_overdue">Overdue</string>
|
<string name="smart_overdue">Overdue</string>
|
||||||
<string name="smart_upcoming">Upcoming</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">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="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) -->
|
<!-- Reminder lead times (custom amounts) -->
|
||||||
<plurals name="reminder_minutes">
|
<plurals name="reminder_minutes">
|
||||||
<item quantity="one">%1$d minute before</item>
|
<item quantity="one">%1$d minute before</item>
|
||||||
|
|||||||
@@ -1,4 +1,22 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<full-backup-content>
|
<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>
|
</full-backup-content>
|
||||||
|
|||||||
@@ -1,8 +1,21 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<data-extraction-rules>
|
<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>
|
<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>
|
</cloud-backup>
|
||||||
<device-transfer>
|
<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>
|
</device-transfer>
|
||||||
</data-extraction-rules>
|
</data-extraction-rules>
|
||||||
|
|||||||
@@ -8,6 +8,4 @@
|
|||||||
-->
|
-->
|
||||||
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
|
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<locale android:name="en" />
|
<locale android:name="en" />
|
||||||
<locale android:name="de" />
|
|
||||||
<locale android:name="pt-BR" />
|
|
||||||
</locale-config>
|
</locale-config>
|
||||||
|
|||||||
@@ -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)
|
val task = TaskMapper.task(reader)
|
||||||
|
|
||||||
assertThat(task.id).isEqualTo(42L)
|
|
||||||
assertThat(task.taskId).isEqualTo(7L)
|
assertThat(task.taskId).isEqualTo(7L)
|
||||||
assertThat(task.listId).isEqualTo(3L)
|
assertThat(task.listId).isEqualTo(3L)
|
||||||
assertThat(task.title).isEqualTo("Buy milk")
|
assertThat(task.title).isEqualTo("Buy milk")
|
||||||
@@ -50,6 +49,65 @@ class TaskMapperTest {
|
|||||||
assertThat(task.isSubtask).isTrue()
|
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
|
@Test
|
||||||
fun `falls back to instance id when task_id missing, and list color when no task color`() {
|
fun `falls back to instance id when task_id missing, and list color when no task color`() {
|
||||||
val task = TaskMapper.task(
|
val task = TaskMapper.task(
|
||||||
|
|||||||
@@ -85,6 +85,57 @@ class TaskWriteMapperTest {
|
|||||||
assertThat(values[Tasks.TZ]).isNull()
|
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
|
@Test
|
||||||
fun `completion sets status, percent and timestamp, un-completion clears them`() {
|
fun `completion sets status, percent and timestamp, un-completion clears them`() {
|
||||||
val done = TaskWriteMapper.completionValues(completed = true, nowMillis = 999L)
|
val done = TaskWriteMapper.completionValues(completed = true, nowMillis = 999L)
|
||||||
@@ -97,6 +148,22 @@ class TaskWriteMapperTest {
|
|||||||
assertThat(undone[Tasks.COMPLETED]).isNull()
|
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
|
@Test
|
||||||
fun `local list uses the LOCAL account`() {
|
fun `local list uses the LOCAL account`() {
|
||||||
val values = TaskWriteMapper.localListValues("Inbox", 0x123)
|
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)
|
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
|
@Test
|
||||||
@@ -27,6 +27,6 @@ class TaskSortingTest {
|
|||||||
|
|
||||||
val sorted = listOf(low, high).sortedWith(TaskSorting.DEFAULT)
|
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,
|
priority: Priority = Priority.NONE,
|
||||||
due: Instant? = null,
|
due: Instant? = null,
|
||||||
): Task = Task(
|
): Task = Task(
|
||||||
id = id,
|
|
||||||
taskId = id,
|
taskId = id,
|
||||||
listId = listId,
|
listId = listId,
|
||||||
title = title,
|
title = title,
|
||||||
@@ -32,6 +31,7 @@ fun testTask(
|
|||||||
accountName = null,
|
accountName = null,
|
||||||
parentId = null,
|
parentId = null,
|
||||||
isRecurring = false,
|
isRecurring = false,
|
||||||
|
occurrenceStart = null,
|
||||||
distanceFromCurrent = 0,
|
distanceFromCurrent = 0,
|
||||||
created = null,
|
created = null,
|
||||||
lastModified = 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.
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application) apply false
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.android.library) apply false
|
||||||
alias(libs.plugins.kotlin.compose) apply false
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
alias(libs.plugins.ksp) apply false
|
alias(libs.plugins.ksp) apply false
|
||||||
alias(libs.plugins.hilt) 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
|
## 1. The thesis in one sentence
|
||||||
|
|
||||||
Agendula is a Material 3 Expressive **front-end** over the OpenTasks
|
Agendula is a Material 3 Expressive task app that reads, writes and reminds
|
||||||
`TaskContract` provider — it reads, writes, and reminds on top of a tasks store
|
against a task store the user chooses: **its own database** (the default) or an
|
||||||
that some other app (DAVx5, SmoothSync, DecSync CC, tasks.org, …) syncs over
|
external provider app already on the device (OpenTasks, tasks.org) synced by
|
||||||
CalDAV. **Agendula owns no database and no sync stack.** It is the task-list
|
DAVx5, SmoothSync, DecSync CC and the like. It is the task-list sibling to
|
||||||
sibling to [Calendula](https://codeberg.org/jlmakiola/calendula),
|
[Calendula](https://codeberg.org/jlmakiola/calendula), which does the same thing
|
||||||
which does the same thing for `CalendarContract`.
|
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 whole design hangs off one rule:
|
||||||
|
|
||||||
> The entire app talks to a `TasksRepository`. Only the data layer knows there
|
> The entire app talks to a `TasksRepository`. Only the data layer knows there
|
||||||
> is a `ContentResolver`, a `TaskContract`, or an authority string behind it.
|
> is a Room database, a `ContentResolver`, a `TaskContract` or an authority
|
||||||
> **Provider column names and the authority string never leak above the data
|
> string behind it. **Table and column names and the authority string never leak
|
||||||
> layer.**
|
> above the data layer.**
|
||||||
|
|
||||||
This is what lets "Posture A" (front-end over an installed provider) become
|
That rule is what let the entire store be swapped without a rewrite: replacing
|
||||||
"Posture B" (bundle the Apache-2.0 provider, be self-contained) without touching
|
the provider with Room changed no UI, no ViewModel and exactly one domain field
|
||||||
the UI, the ViewModels, or the domain. See §7.
|
(`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 + Flows only
|
||||||
┌───────────────▼──────────────────────────────┐
|
┌───────────────▼──────────────────────────────┐
|
||||||
Domain │ Models, TaskForm, TaskFilter, TaskSorting, │
|
Domain │ Models, TaskForm, TaskFilter, TaskSorting, │
|
||||||
│ DayWindow (pure Kotlin, no Android) │
|
│ TaskSections, RecurrenceExpander │
|
||||||
|
│ (pure Kotlin, no Android) │
|
||||||
└───────────────┬──────────────────────────────┘
|
└───────────────┬──────────────────────────────┘
|
||||||
│ TasksRepository (interface)
|
│ TasksRepository (interface)
|
||||||
┌───────────────▼──────────────────────────────┐
|
┌───────────────▼──────────────────────────────┐
|
||||||
Data │ TasksRepositoryImpl │
|
Data │ TasksRepositoryImpl │
|
||||||
│ └ TasksDataSource (interface) │
|
│ └ TasksDataSource (interface) │
|
||||||
│ └ AndroidTasksDataSource │
|
│ └ ModeRoutingTasksDataSource │
|
||||||
│ └ ContentResolver / TaskContract / │
|
│ ├ RoomTasksDataSource (OWN) │
|
||||||
│ ProviderResolver / ContentObserver│
|
│ └ AndroidTasksDataSource (EXTERNAL)│
|
||||||
│ reminders/ prefs/ di/ demo/ │
|
│ reminders/ prefs/ di/ demo/ │
|
||||||
└───────────────┬──────────────────────────────┘
|
└────────┬────────────────────────┬────────────┘
|
||||||
│ content:// + dangerous perms
|
│ Room DAOs │ content://
|
||||||
┌───────────────▼──────────────────────────────┐
|
┌─────────────▼──────────┐ ┌──────────▼─────────────┐
|
||||||
External │ OpenTasks provider ←sync← DAVx5 / DecSync… │
|
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:
|
The seam that matters is the pair of interfaces in the data layer:
|
||||||
|
|
||||||
- **`TasksRepository`** — the only type the UI sees. Flow-based reads, suspend
|
- **`TasksRepository`** — the only type the UI sees. Flow-based reads, suspend
|
||||||
writes. (`data/tasks/TasksRepository.kt`)
|
writes. (`data/tasks/TasksRepository.kt`)
|
||||||
- **`TasksDataSource`** — the JVM-testable interface that does the actual
|
- **`TasksDataSource`** — the JVM-testable, domain-shaped interface that does the
|
||||||
provider work; `AndroidTasksDataSource` is the only Android-coupled
|
actual store work. Two implementations: `RoomTasksDataSource` for our own
|
||||||
implementation.
|
store and `AndroidTasksDataSource` for an external provider, picked per call by
|
||||||
|
`ModeRoutingTasksDataSource` (§4.1).
|
||||||
|
|
||||||
Both are bound in Hilt in `data/di/DataModule.kt`.
|
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
|
## 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. |
|
| `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`. |
|
||||||
| `data/tasks/` | `TasksContract` (vendored subset), `ProviderResolver` (the A/B seam), `TaskProjections`, `ColumnReader`, `TaskMapper` (cursor→domain), `TaskWriteMapper` (form→`ContentValues`), `TasksDataSource` + `AndroidTasksDataSource`, `TasksRepository` + `Impl`, `Failures`. |
|
| `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/reminders/` | `ReminderScheduler` (the self-scheduled engine), `DueReminderReceiver`, `BootReceiver`, `ProviderChangeReceiver`, `ScheduledReminderStore`, `TaskNotifier`. |
|
||||||
| `data/prefs/` | `SettingsPrefs` (DataStore). |
|
| `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). |
|
| `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`. |
|
| root | `AgendulaApp` (Hilt app), `MainActivity`. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. The data layer (the heart)
|
## 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
|
`StorageMode` has two values, `OWN` and `EXTERNAL`, and
|
||||||
returns the first provider actually installed (via
|
`ModeRoutingTasksDataSource` picks the implementation **per call** — the mode is
|
||||||
`PackageManager.resolveContentProvider`), or `null` if none is. Each candidate
|
a setting the user can change while the process lives, so binding it once would
|
||||||
is a `TaskProvider(authority, readPermission, writePermission, packageName)`.
|
mean rebuilding the object graph to honour a change.
|
||||||
|
|
||||||
| Provider | Authority | Permissions |
|
| Mode | Store | Authority | Permissions |
|
||||||
|---|---|---|
|
|---|---|---|---|
|
||||||
| OpenTasks | `org.dmfs.tasks` | `org.dmfs.permission.READ_TASKS` / `WRITE_TASKS` |
|
| **Own** (default) | our Room database, `agendula-tasks.db` | — none, it is not a provider | **none** |
|
||||||
| tasks.org | `org.tasks.opentasks` | `org.tasks.permission.READ_TASKS` / `WRITE_TASKS` |
|
| 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`
|
`ProviderResolver` is now only about the second and third rows: it discovers the
|
||||||
columns apply** regardless of which is present. `null` from `resolve()` drives
|
*external* providers a device has. `resolve()` returns `null` in `OWN` mode —
|
||||||
the "install a tasks provider" onboarding gate. `hasPermission()` checks both
|
there is no authority to resolve — and callers that need to tell that apart from
|
||||||
runtime perms for the active provider.
|
"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,
|
A vendored subset of the Apache-2.0 OpenTasks `TaskContract` — column names,
|
||||||
table paths, status/priority constants, the local-account type. Agendula does
|
table paths, status/priority constants, the local-account type. Agendula does
|
||||||
**not** take a runtime dependency on OpenTasks; the authority is injected from
|
**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
|
In `OWN` mode `RoomTasksDataSource` reads through the DAOs and expands
|
||||||
occurrence is a row with the joined list colour, account, etc.), maps each
|
recurrences (§4.3). In `EXTERNAL` mode `AndroidTasksDataSource` queries the
|
||||||
cursor row through `ColumnReader` → `TaskMapper` → domain `Task`, and exposes
|
denormalized **instances** view (so each occurrence is a row with the joined list
|
||||||
the result as a `Flow`. A `ContentObserver` on the active authority's
|
colour, account, etc.) and maps each cursor row through `ColumnReader` →
|
||||||
Tasks/TaskLists URIs bridges into the Flow via `callbackFlow`, so **any change
|
`TaskMapper` → domain `Task`.
|
||||||
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.
|
|
||||||
|
|
||||||
### 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
|
```kotlin
|
||||||
interface TasksRepository {
|
interface TasksRepository {
|
||||||
fun taskLists(): Flow<List<TaskList>>
|
fun taskLists(): Flow<List<TaskList>>
|
||||||
fun tasks(filter: TaskFilter): Flow<List<Task>>
|
fun tasks(filter: TaskFilter): Flow<List<Task>>
|
||||||
|
fun subtasks(parentId: Long): Flow<List<Task>>
|
||||||
fun taskDetail(taskId: Long): Flow<TaskDetail?>
|
fun taskDetail(taskId: Long): Flow<TaskDetail?>
|
||||||
|
|
||||||
suspend fun createTask(form: TaskForm): Long
|
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 setCompleted(taskId: Long, completed: Boolean) // the core gesture
|
||||||
suspend fun deleteTask(taskId: Long)
|
suspend fun deleteTask(taskId: Long)
|
||||||
|
suspend fun reminderFor(taskId: Long): Int?
|
||||||
suspend fun createLocalList(name: String, color: Int): Long
|
suspend fun createLocalList(name: String, color: Int): Long
|
||||||
|
|
||||||
fun providerStatus(): ProviderStatus // READY | NEEDS_PERMISSION | NO_PROVIDER
|
fun providerStatus(): ProviderStatus // READY | NEEDS_PERMISSION | NO_PROVIDER
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`TaskWriteMapper` turns a validated `TaskForm` into `ContentValues`. Completion
|
`updateTask` on an occurrence of a recurring task routes to
|
||||||
sets `STATUS = COMPLETED` (+ percent/completed timestamp); DAVx5 syncs that back
|
`TasksDataSource.updateInstance(taskId, occurrenceStart, form)` rather than
|
||||||
out as a normal VTODO status change. Writes to local/unsynced lists use the
|
moving the series anchor. `expectedLastModified` re-checks the stored timestamp
|
||||||
sync-adapter URI form where the provider requires it.
|
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
|
### 4.8 Domain model notes
|
||||||
`tasks._id` and the stable target for edits/completion.
|
|
||||||
|
- `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`);
|
- Subtasks are carried via `parentId` (`RELATED-TO` / `RELATION_TYPE_PARENT`);
|
||||||
`TaskDetail` bundles a task with its direct children.
|
`TaskDetail` bundles a task with its direct children.
|
||||||
- `effectiveColor` = the task's own colour, else the list colour.
|
- `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 —
|
`TaskFilter` is either `OfList(listId)` or `Smart(SmartList)`. The smart lists —
|
||||||
`ALL, TODAY, UPCOMING, OVERDUE, NO_DATE, COMPLETED` — are computed from due
|
`ALL, TODAY, UPCOMING, OVERDUE, NO_DATE, COMPLETED` — are computed from due
|
||||||
dates, not membership. `TaskFiltering.matches()` is a **pure predicate** taking
|
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 /
|
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
|
priority / etc.). None of this touches Android, which is why it's all in
|
||||||
`domain/`.
|
`domain/`.
|
||||||
@@ -181,10 +368,24 @@ providers broadcast nothing**, so Agendula schedules its own (`data/reminders/`)
|
|||||||
`set` when `canScheduleExactAlarms()` is false), keyed by `taskId`.
|
`set` when `canScheduleExactAlarms()` is false), keyed by `taskId`.
|
||||||
- **`DueReminderReceiver`** fires → posts via `TaskNotifier` (channel,
|
- **`DueReminderReceiver`** fires → posts via `TaskNotifier` (channel,
|
||||||
`POST_NOTIFICATIONS` gate, dedupe-by-tag).
|
`POST_NOTIFICATIONS` gate, dedupe-by-tag).
|
||||||
- **Re-sync triggers:** app start, **`BootReceiver`** (re-arm after reboot), and
|
- **Re-sync triggers:** app start (after `StartupGate`), **`BootReceiver`**
|
||||||
**`ProviderChangeReceiver`** (`PROVIDER_CHANGED` on both authorities → external
|
(re-arm after reboot), and **`ProviderChangeReceiver`**. The store lets each
|
||||||
sync changed the data). The store lets each run diff like Calendula diffs
|
run diff like Calendula diffs reminder rows.
|
||||||
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.
|
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)
|
## 7. The A / B seam (why the layering is shaped this way)
|
||||||
|
|
||||||
- **Posture A (today):** front-end over whatever provider is installed. Ships
|
Both terms were **redefined** by [`STORAGE-AND-SYNC.md`](STORAGE-AND-SYNC.md).
|
||||||
fast; requires a provider app present (the "needs DAVx5/OpenTasks" onboarding
|
They no longer mean what earlier drafts of this document said.
|
||||||
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.
|
|
||||||
|
|
||||||
Bundling the provider bundles **storage, not sync** — Agendula stays a pure
|
- **Posture A** — front-end over an *external* provider (OpenTasks, tasks.org).
|
||||||
front-end over open backends either way.
|
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`
|
`RootScreen` is the entry composable: it gates on `ProviderStatus`
|
||||||
(`NO_PROVIDER` / `NEEDS_PERMISSION` → onboarding `Gate`; `READY` →
|
(`NO_PROVIDER` / `NEEDS_PERMISSION` → onboarding `Gate`; `READY` →
|
||||||
`ListsScreen`). The remaining screens are being built one at a time — their
|
`AgendulaNavHost`). In `OWN` mode the status is always `READY`, so that gate is
|
||||||
ViewModels exist and are tested against the real data layer; navigation
|
only ever seen in External mode — and it offers a way back to our own store,
|
||||||
callbacks are currently stubs (see [`ROADMAP.md`](ROADMAP.md)). Follow the
|
because it is the only screen an External user can reach once their provider app
|
||||||
`material-3` skill for component choices (M3 `ListItem` rows, expressive
|
stops answering. Routes are the `Dest` table in `ui/navigation/` (lists → task
|
||||||
checkbox/FAB/swipe motion).
|
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
|
## 9. Dependency injection
|
||||||
|
|
||||||
Hilt, `SingletonComponent`. `DataModule` has a `@Binds` module
|
Hilt, `SingletonComponent`. `DataModule` has a `@Binds` module (`TasksRepository`
|
||||||
(`TasksDataSource` → `AndroidTasksDataSource`, `TasksRepository` →
|
→ `TasksRepositoryImpl`, `ProviderEnvironment` → `AndroidProviderEnvironment`)
|
||||||
`TasksRepositoryImpl`) and a `@Provides` module (the `agendula_prefs` DataStore,
|
and a `@Provides` module (the `agendula_prefs` DataStore, `TasksDatabase`, the
|
||||||
the `@IoDispatcher`). `AgendulaApp` is the `@HiltAndroidApp` entry point;
|
`@IoDispatcher`, the `@ApplicationScope`). `TasksDataSource` is `@Provides`
|
||||||
`MainActivity` is `@AndroidEntryPoint`. ViewModels get the repository injected.
|
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 |
|
| 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 |
|
| 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) |
|
| 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 |
|
| 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) |
|
||||||
| Tests | JUnit5 (Jupiter) + Truth + Turbine + coroutines-test; the data source is the JVM-testable seam |
|
| 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). |
|
| 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). |
|
| 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 |
|
| Distribution | F-Droid (`fdroid-metadata/`) + Codeberg release APKs |
|
||||||
@@ -252,12 +500,27 @@ the `@IoDispatcher`). `AgendulaApp` is the `@HiltAndroidApp` entry point;
|
|||||||
## 11. Manifest surface
|
## 11. Manifest surface
|
||||||
|
|
||||||
- **Permissions:** both `org.dmfs.*` and `org.tasks.*` read/write tasks perms
|
- **Permissions:** both `org.dmfs.*` and `org.tasks.*` read/write tasks perms
|
||||||
declared statically (the active set is requested at runtime);
|
(static manifest, so they are always declared; requested at runtime only in
|
||||||
`POST_NOTIFICATIONS`, `RECEIVE_BOOT_COMPLETED`, exact-alarm
|
External mode); `POST_NOTIFICATIONS`, `RECEIVE_BOOT_COMPLETED`, exact-alarm
|
||||||
(`USE_EXACT_ALARM` on 33+, `SCHEDULE_EXACT_ALARM` ≤32).
|
(`USE_EXACT_ALARM` on 33+, `SCHEDULE_EXACT_ALARM` ≤32). `OWN` mode needs
|
||||||
- **`<queries>`** for package visibility: both provider authorities + a LAUNCHER
|
nothing here at all: it is a database in our own data directory.
|
||||||
intent (so `resolveContentProvider` works and onboarding can open the
|
- **No `<provider>` and no custom permissions.** Agendula publishes no content
|
||||||
provider / a store listing).
|
provider; `de.jeanlucmakiola.agendula.tasks`, the
|
||||||
- **Receivers:** `DueReminderReceiver` (not exported), `BootReceiver`,
|
`de.jeanlucmakiola.agendula.permission.*` pair and their permission group all
|
||||||
`ProviderChangeReceiver` (both authorities). No `EVENT_REMINDER` receiver —
|
went with the `:provider` module.
|
||||||
that's a Calendula thing that doesn't apply here.
|
- **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
|
# 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
|
> A modern Material 3 Expressive **task** app for Android. Reads, writes, and
|
||||||
> reminds — on top of an existing tasks provider (synced by DAVx5 / SmoothSync /
|
> reminds — on top of an existing tasks provider (synced by DAVx5 / SmoothSync /
|
||||||
> DecSync over CalDAV), with no own sync stack.
|
> DecSync over CalDAV), with no own sync stack.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user