11 Commits

Author SHA1 Message Date
Jean-Luc Makiola
d7131087cb Merge pull request 'release: cut 0.4.0 — ship German and Brazilian Portuguese' (#9) from release/v0.4.0 into main
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 5s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Successful in 12m30s
2026-08-31 19:02:24 +02:00
dfb0a694de release: cut 0.4.0 — the first translated release
Ships the first community translations: German (98%) and Brazilian
Portuguese (100%), both from Weblate, plus the translation infrastructure
and the Codeberg forge move that had accumulated under Unreleased.

A minor bump rather than a patch: two new user-facing languages is a
feature, the way 0.3.0 was cut for Reminders.

versionCode is the derived 400 (MAJOR*10000 + MINOR*100 + PATCH), and
scripts/sync_changelog_to_fastlane.sh generated changelogs/400.txt so the
official F-Droid listing shows this version's "What's New". At 908 chars
it exceeds the script's 500-char advisory and may be truncated in-client
— consistent with past feature releases (0.2.0 ran 1811, 0.3.0 692).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146mhMLZ5AqJSZkADDc92Jz
2026-08-31 18:27:25 +02:00
79970b5c65 l10n: make German and Brazilian Portuguese selectable
The Weblate merge (#8) landed values-de and values-pt-rBR, but
locales_config.xml still listed only `en`. That file is the single source
of truth for which languages the app ships: AppLanguage parses it for the
in-app picker (Settings -> App language) and android:localeConfig points
the system per-app language settings at it. So both translations were
compiled into the APK and reachable by nothing — you could not pick either
one, and only a device already set to de/pt-BR would ever have seen them.

Adds the two BCP-47 tags. Note the tags are `de` and `pt-BR` while the
resource folders are values-de and values-pt-rBR; the `r` is an Android
resource-qualifier artefact and must not appear here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146mhMLZ5AqJSZkADDc92Jz
2026-08-31 18:27:14 +02:00
Jean-Luc Makiola
5b31067e43 Translations update from Weblate (#8)
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/8
2026-08-31 18:19:53 +02:00
Bernardo Krzysczak
c69ff049a0 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (182 of 182 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/pt_BR/
2026-08-21 07:41:09 +00:00
Bernardo Krzysczak
81220d53d1 Added translation using Weblate (Portuguese (Brazil)) 2026-08-20 13:18:29 +00:00
b3091a5aa3 Translated using Weblate (German)
Currently translated at 98.9% (180 of 182 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/de/
2026-08-18 01:04:09 +00:00
Weblate
b1bde2fab5 Translated using Weblate (German)
Currently translated at 98.9% (180 of 182 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/de/
2026-08-18 01:04:09 +00:00
abc381ca24 Translated using Weblate (German)
Currently translated at 98.8% (177 of 179 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/de/
2026-08-14 13:22:24 +00:00
Weblate
1fbd91fe82 Translated using Weblate (German)
Currently translated at 98.8% (177 of 179 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/de/
2026-08-14 13:22:24 +00:00
c2087de639 Added translation using Weblate (German) 2026-08-14 13:22:24 +00:00
108 changed files with 984 additions and 11159 deletions

4
.gitignore vendored
View File

@@ -65,7 +65,3 @@ Thumbs.db
# KSP
.ksp/
# Local agent notes: machine-specific build setup and on-device rules, not
# anything the project itself depends on.
/CLAUDE.md

View File

@@ -7,7 +7,14 @@ All notable changes to this project are documented here. The format follows
## [Unreleased]
## [0.4.0] - 2026-08-31
### 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
translate in the browser — the link sits at the top of the language picker in
**Settings → App language**. Partial translations are fine; anything

View File

@@ -3,8 +3,8 @@
<h1>Agendula</h1>
<p><strong>A modern Material 3 Expressive task app for Android.</strong><br>
Keeps your tasks on your device, or on top of a tasks provider you already use.
Open standards, no account required.</p>
Reads, writes, and reminds — on top of an existing tasks provider, with no own
sync stack.</p>
<a href="https://codeberg.org/jlmakiola/agendula/actions"><img src="https://codeberg.org/jlmakiola/agendula/actions/workflows/ci.yaml/badge.svg?branch=main" alt="CI"></a>
<img src="https://img.shields.io/badge/Android-10%2B-3DDC84?logo=android&logoColor=white" alt="Android 10+">
@@ -15,53 +15,31 @@ Open standards, no account required.</p>
</div>
Agendula is the task-list sibling to [Calendula](https://codeberg.org/jlmakiola/calendula).
Where Calendula is a pure front-end over Android's `CalendarContract`, Agendula
keeps its own store, designed around RFC 5545's `VTODO` — the same tasks DAVx5
(and SmoothSync, DecSync, …) sync out of your CalDAV server. It can also read and
write a tasks provider you already have, for anyone already syncing that way.
Where Calendula is a pure front-end over Android's `CalendarContract`, Agendula is
a pure front-end over the **OpenTasks `TaskContract` provider** — the store that
DAVx5 (and SmoothSync, DecSync, …) syncs your CalDAV `VTODO` tasks into. No own
database, no reinvented sync.
The name rhymes with its sibling on purpose: **Agendula** is *agenda* — Latin for
“things to be done” — given Calendula's `-ula` ending. Calendula keeps your days;
Agendula keeps your to-dos. (A Calendula flower head is botanically a cluster of
many small *florets* — so the two apps are florets of one bloom.)
## Where your tasks live — your choice
| | Where | Sync | Needs |
|---|---|---|---|
| **On your device** *(default)* | Agendula's own database | none yet — CalDAV sync of our own is planned | nothing. No account, no permissions, no other app |
| **In a provider you already use** | OpenTasks or tasks.org | whatever syncs it for you — DAVx5 and friends | that app installed, and its read/write permission |
Agendula's own store is an ordinary app database — nothing is published to other
apps, so there is no authority to clash over and no permission to grant. It
**coexists with OpenTasks rather than replacing it**: installing one never breaks
the other, and if you already sync through a provider, that keeps working exactly
as it did.
Recurring tasks are expanded per RFC 5545, and everything the schema does not
model is round-tripped verbatim rather than dropped — so passing your tasks
through Agendula does not quietly lose fields a server sent.
Your tasks are exportable as standard iCalendar `.ics` files at any time, because
data you can't take with you isn't really yours.
> **Status: backend complete, UI catching up.** Storage, reads and
> writes, smart-list filtering, a self-scheduled reminder engine, and export are
> built and unit-tested. The Material 3 Expressive screens are being built on
> top, one at a time — the storage-mode picker and export screen are not there
> yet. See [`docs/ROADMAP.md`](docs/ROADMAP.md) for status,
> **Status: data layer done, UI in progress.** The full non-visual stack over
> the `TaskContract` provider — provider resolution, live-updating reads,
> writes, smart-list filtering, and a self-scheduled reminder engine — is built
> and unit-tested. The Material 3 Expressive screens are now being built on top,
> one at a time. See [`docs/ROADMAP.md`](docs/ROADMAP.md) for status,
> [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for how it's built, and
> [`docs/STORAGE-AND-SYNC.md`](docs/STORAGE-AND-SYNC.md) for why storage works
> the way it does.
> [`docs/PLAN.md`](docs/PLAN.md) for the A-now-B-later design rationale.
## Sync sources (by design)
In external-provider mode Agendula works with anything that writes to that provider —
**DAVx5** (CalDAV), **SmoothSync**, **CalDAV-Sync**, **DecSync CC**, or any
Android sync adapter — because it builds on the provider, not on any one sync
app. Google Tasks / Microsoft To Do are out of scope by design (proprietary; they
would mean owning a sync stack). Open standards — CalDAV / iCalendar / DecSync —
are the lane.
Agendula works with anything that writes to the tasks provider — **DAVx5**
(CalDAV), **SmoothSync**, **CalDAV-Sync**, **DecSync CC**, or any Android sync
adapter — because it builds on the provider, not on any one sync app. Google
Tasks / Microsoft To Do are out of scope by design (proprietary; they would mean
owning a sync stack). Open standards — CalDAV / iCalendar / DecSync — are the lane.
## Translations

View File

@@ -29,8 +29,8 @@ android {
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
// 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.
versionCode = 302
versionName = "0.3.2"
versionCode = 400
versionName = "0.4.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -129,10 +129,6 @@ android {
isReturnDefaultValues = true
}
}
// MigrationTestHelper reads the exported schemas out of the test APK's
// assets, so app/schemas/ has to ship with the instrumented tests.
sourceSets.getByName("androidTest").assets.srcDir("$projectDir/schemas")
}
kotlin {
@@ -141,27 +137,11 @@ kotlin {
}
}
// Export each Room schema version to app/schemas/ and commit it. That JSON is
// what MigrationTestHelper reads to build an old database and migrate it, so
// without it a migration can only be tested by hand.
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
dependencies {
// Not a dependency we use directly — lifecycle already drags it in at 1.7.3.
// AGP's consistent resolution then pins androidTest to the app classpath, and
// room-testing's MigrationTestHelper needs 1.8+ to deserialize the exported
// schema; on 1.7.3 it dies with an AbstractMethodError. Raise it in one place.
constraints {
implementation(libs.kotlinx.serialization.json)
}
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
@@ -177,17 +157,7 @@ dependencies {
implementation(libs.androidx.navigation.compose)
ksp(libs.hilt.compiler)
// RFC 5545 recurrence expansion, in-process. Pinned at 0.12.2 — 0.16.0
// removed RecurrenceSet. rfc5545-datetime comes with it and is part of its
// API surface, so it isn't declared separately.
implementation(libs.dmfs.lib.recur)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.documentfile)
implementation(libs.androidx.glance.appwidget)
implementation(libs.androidx.glance.material3)
@@ -215,7 +185,6 @@ dependencies {
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.test.rules)
androidTestImplementation(libs.truth)
androidTestImplementation(libs.androidx.room.testing)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
}

View File

@@ -1,522 +0,0 @@
{
"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')"
]
}
}

View File

@@ -1,290 +0,0 @@
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
}
}

View File

@@ -1,389 +0,0 @@
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()
}
}

View File

@@ -1,68 +0,0 @@
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"
}
}

View File

@@ -1,107 +0,0 @@
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
}
}

View File

@@ -1,155 +0,0 @@
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")
}
}

View File

@@ -1,274 +0,0 @@
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
}
}

View File

@@ -2,15 +2,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- External tasks-provider access, for StorageMode.EXTERNAL only. Both
permission sets are declared, since the manifest is static; the active one
<!-- Tasks provider access. Both permission sets are declared; the active one
(org.tasks.* for tasks.org, org.dmfs.* for OpenTasks) is requested at
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. -->
runtime by the permission flow. Both are dangerous-level. -->
<uses-permission android:name="org.dmfs.permission.READ_TASKS" />
<uses-permission android:name="org.dmfs.permission.WRITE_TASKS" />
<uses-permission android:name="org.tasks.permission.READ_TASKS" />
@@ -80,11 +74,8 @@
</intent-filter>
</receiver>
<!-- Re-sync reminders when an external provider changes — DAVx5 pulling
tasks while Agendula is backgrounded. External mode only: in OWN mode
nothing outside the app can change our data, and Room's
InvalidationTracker covers our own writes. An intent-filter host must
be a literal, so both external authorities are listed. -->
<!-- Re-sync reminders when the provider changes (external DAVx5 sync).
Targets both known authorities; the host must be static. -->
<receiver
android:name=".data.reminders.ProviderChangeReceiver"
android:exported="true">

View File

@@ -1,22 +1,18 @@
package de.jeanlucmakiola.agendula
import android.app.Application
import androidx.lifecycle.ProcessLifecycleOwner
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.HiltAndroidApp
import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StartupGate
import de.jeanlucmakiola.agendula.data.tasks.room.DatabaseCheckpoint
import de.jeanlucmakiola.floret.crash.CrashConfig
import de.jeanlucmakiola.floret.crash.CrashReporter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean
/**
* Application entry point. Registered as android:name=".AgendulaApp". Besides
@@ -39,42 +35,17 @@ class AgendulaApp : Application() {
issueTitle = getString(R.string.crash_report_issue_title),
),
)
val entryPoint = EntryPointAccessors.fromApplication(this, AppEntryPoint::class.java)
val scheduler = entryPoint.reminderScheduler()
// Mirror the stored storage mode into ProviderResolver and import a
// v0.3.x install's tasks, both before anything reads a store.
val startupGate = entryPoint.startupGate()
val scope = entryPoint.applicationScope()
// An alarm is armed off whichever store was active when it was scheduled,
// so a switch has to rebuild the set. Armed only once startup's own
// null -> stored transition is past, which the launch sync below covers.
val started = AtomicBoolean(false)
entryPoint.providerResolver().onModeChanged {
if (started.get()) scope.launch { runCatching { scheduler.sync() } }
}
startupGate.start()
ProcessLifecycleOwner.get().lifecycle.addObserver(entryPoint.databaseCheckpoint())
scope.launch {
// Wait for the stored mode and the import to land first. Rescheduling
// alarms against whichever store autoMode happens to pick would arm
// them off the wrong one — or off an empty one, mid-import.
runCatching {
startupGate.awaitReady()
started.set(true)
scheduler.sync()
}
val scheduler = EntryPointAccessors
.fromApplication(this, ReminderEntryPoint::class.java)
.reminderScheduler()
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
runCatching { scheduler.sync() }
}
}
@EntryPoint
@InstallIn(SingletonComponent::class)
interface AppEntryPoint {
interface ReminderEntryPoint {
fun reminderScheduler(): ReminderScheduler
fun startupGate(): StartupGate
fun providerResolver(): ProviderResolver
@ApplicationScope
fun applicationScope(): CoroutineScope
fun databaseCheckpoint(): DatabaseCheckpoint
}
}

View File

@@ -50,7 +50,7 @@ class DemoSeeder @Inject constructor(
repository.createTask(TaskForm(title = "Sketch the Agendula app icon", listId = listId))
val done = repository.createTask(TaskForm(title = "Renew domain name", listId = listId, due = at(ts - 2 * day)))
repository.setCompleted(done, occurrenceStart = null, completed = true)
repository.setCompleted(done, completed = true)
}
private companion object {

View File

@@ -3,8 +3,6 @@ package de.jeanlucmakiola.agendula.data.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.datastore.preferences.preferencesDataStore
import dagger.Binds
import dagger.Module
@@ -12,21 +10,12 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.agendula.data.tasks.AndroidProviderEnvironment
import de.jeanlucmakiola.agendula.data.tasks.AndroidTasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.ModeRoutingTasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.ProviderEnvironment
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.TasksRepositoryImpl
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import javax.inject.Provider
import javax.inject.Singleton
private val Context.agendulaDataStore: DataStore<Preferences> by preferencesDataStore(
@@ -39,11 +28,11 @@ abstract class DataBindModule {
@Binds
@Singleton
abstract fun bindTasksRepository(impl: TasksRepositoryImpl): TasksRepository
abstract fun bindTasksDataSource(impl: AndroidTasksDataSource): TasksDataSource
@Binds
@Singleton
abstract fun bindProviderEnvironment(impl: AndroidProviderEnvironment): ProviderEnvironment
abstract fun bindTasksRepository(impl: TasksRepositoryImpl): TasksRepository
}
@Module
@@ -55,41 +44,7 @@ object DataProvideModule {
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
context.agendulaDataStore
@Provides
@Singleton
fun provideTasksDatabase(@ApplicationContext context: Context): TasksDatabase =
Room.databaseBuilder(context, TasksDatabase::class.java, TasksDatabase.NAME)
// Room's default, stated rather than assumed: Auto Backup copies files
// without checkpointing, so a `-wal` sidecar can hold writes the
// backed-up `.db` does not. The backup rules carry all three files and
// the app checkpoints on ON_STOP.
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
.build()
/**
* The active store, chosen by [StorageMode].
*
* Resolved per injection point rather than bound once, because the mode is a
* user setting that [de.jeanlucmakiola.agendula.data.tasks.StorageModeHolder]
* can change while the process lives. Both implementations are singletons, so
* this picks between two long-lived objects rather than building either.
*/
@Provides
@Singleton
fun provideTasksDataSource(
resolver: ProviderResolver,
room: Provider<RoomTasksDataSource>,
external: Provider<AndroidTasksDataSource>,
): TasksDataSource = ModeRoutingTasksDataSource(resolver, room, external)
@Provides
@IoDispatcher
fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
@Provides
@Singleton
@ApplicationScope
fun provideApplicationScope(): CoroutineScope =
// SupervisorJob so one failing collector can't take the others down with it.
CoroutineScope(SupervisorJob() + Dispatchers.Default)
}

View File

@@ -6,13 +6,3 @@ import javax.inject.Qualifier
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class IoDispatcher
/**
* Marks the process-lifetime [kotlinx.coroutines.CoroutineScope] — for work that
* outlives any screen and has nothing to be cancelled by, such as keeping the
* selected storage mode mirrored out of DataStore. It is never cancelled, so
* don't launch anything unbounded in it.
*/
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class ApplicationScope

View File

@@ -1,132 +0,0 @@
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"
}
}

View File

@@ -1,76 +0,0 @@
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"
}
}
}

View File

@@ -8,7 +8,6 @@ import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec
@@ -83,31 +82,6 @@ class SettingsPrefs @Inject constructor(
suspend fun setReminderLeadMinutes(minutes: Int) = dataStore.edit { it[REMINDER_LEAD] = minutes }
/**
* Which task store backs the app, or `null` while the user has not chosen —
* which is the normal state, since most people never open Settings.
*
* Kept out of [Settings] on purpose. Everything in there is a rendering
* preference collected by the UI; this one selects an authority in the data
* layer, is read on paths that must not wait for a whole settings object, and
* `null` genuinely means "undecided" rather than "default" — the difference
* matters, because undecided is what lets `ProviderResolver.autoMode` keep an
* upgrading Posture A user pointed at the provider that holds their data.
*/
val storageMode: Flow<StorageMode?> = dataStore.data.map { p ->
when (val stored = p[STORAGE_MODE]) {
null -> null
// 0.3.x's value for the bundled dmfs provider. That store is gone and
// its data was imported into OWN, so read it as OWN rather than
// letting it fall through to autoMode — someone who chose local
// storage explicitly would otherwise be sent to an external provider.
"LOCAL" -> StorageMode.OWN
else -> runCatching { StorageMode.valueOf(stored) }.getOrNull()
}
}
suspend fun setStorageMode(mode: StorageMode) = dataStore.edit { it[STORAGE_MODE] = mode.name }
/** One-time reminder onboarding gate; false until the step has been shown. */
val reminderOnboardingDone: Flow<Boolean> = dataStore.data.map { it[REMINDER_ONBOARDING_DONE] ?: false }
@@ -139,7 +113,6 @@ class SettingsPrefs @Inject constructor(
val SHOW_ADD_SUBTASK_ROW = booleanPreferencesKey("show_add_subtask_row")
val BOTTOM_ADD_BAR = booleanPreferencesKey("bottom_add_bar")
val REMINDER_ONBOARDING_DONE = booleanPreferencesKey("reminder_onboarding_done")
val STORAGE_MODE = stringPreferencesKey("storage_mode")
val LIST_REMINDER_OVERRIDE = stringPreferencesKey("list_reminder_override")
val DEFAULT_EDIT_FIELDS = stringSetPreferencesKey("default_edit_fields")
}

View File

@@ -3,7 +3,6 @@ package de.jeanlucmakiola.agendula.data.reminders
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.net.toUri
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
@@ -46,14 +45,7 @@ class DueReminderReceiver : BroadcastReceiver() {
companion object {
private const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID"
/**
* [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)
fun intent(context: Context, taskId: Long): Intent =
Intent(context, DueReminderReceiver::class.java).putExtra(EXTRA_TASK_ID, taskId)
}
}

View File

@@ -3,9 +3,7 @@ package de.jeanlucmakiola.agendula.data.reminders
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.SystemClock
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -22,25 +20,10 @@ import javax.inject.Inject
class ProviderChangeReceiver : BroadcastReceiver() {
@Inject lateinit var scheduler: ReminderScheduler
@Inject lateinit var providerResolver: ProviderResolver
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
override fun onReceive(context: Context, intent: Intent) {
// The receiver has to stay exported to hear the provider's broadcast, and
// the sender holds no permission we could require — so validate the
// broadcast itself. Without this, any installed app can spam a full
// re-sync (an unbounded provider read) by firing a matching intent.
if (intent.action != Intent.ACTION_PROVIDER_CHANGED) return
val authority = providerResolver.resolve()?.authority ?: return
if (intent.data?.host != authority) return
// External sync can fire these in bursts; one re-sync per burst is plenty.
val now = SystemClock.elapsedRealtime()
synchronized(Companion) {
if (now - lastSyncAt < MIN_SYNC_INTERVAL_MS) return
lastSyncAt = now
}
val pending = goAsync()
scope.launch {
try {
@@ -50,11 +33,4 @@ class ProviderChangeReceiver : BroadcastReceiver() {
}
}
}
private companion object {
const val MIN_SYNC_INTERVAL_MS = 10_000L
@Volatile
var lastSyncAt = -MIN_SYNC_INTERVAL_MS
}
}

View File

@@ -12,18 +12,15 @@ import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* The self-scheduled due-reminder engine. Nothing else delivers task reminders —
* not the platform, not a tasks provider so Agendula reads upcoming due tasks
* and arms one exact [AlarmManager] alarm each, within a rolling window. Re-run
* on app start, on boot, on a store switch, and on an external provider change;
* it diffs against [ScheduledReminderStore] so only changed alarms move.
* The self-scheduled due-reminder engine. Tasks providers don't deliver
* reminders, so Agendula reads upcoming due tasks and arms one exact [AlarmManager]
* alarm each, within a rolling window. Re-run on app start, boot and provider
* change; it diffs against [ScheduledReminderStore] so only changed alarms move.
*/
@Singleton
class ReminderScheduler @Inject constructor(
@@ -34,89 +31,47 @@ class ReminderScheduler @Inject constructor(
private val providerResolver: ProviderResolver,
@IoDispatcher private val io: CoroutineDispatcher,
) {
private val syncLock = Mutex()
/**
* Diff the armed alarms against the store and move only what changed.
*
* Serialised: the diff is a read-modify-write over [ScheduledReminderStore],
* and callers overlap (a store switch fires this while the launch sync may
* still be running). Two interleaved runs would each write their own set as
* the whole truth, leaving the other's alarms armed but unrecorded — never
* cancelled, and firing against the wrong store's task ids.
*/
suspend fun sync() = withContext(io) { syncLock.withLock { syncLocked() } }
private suspend fun syncLocked() {
suspend fun sync() = withContext(io) {
val provider = providerResolver.resolve()
val settings = settingsPrefs.settings.first()
// 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()) {
if (provider == null || !providerResolver.hasPermission(provider) || !settings.remindersEnabled) {
clearAll()
return
return@withContext
}
val now = System.currentTimeMillis()
val horizon = now + WINDOW_MS
val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) }
.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() }
.getOrElse { return@withContext }
val desired = tasks
.filter { !it.isClosed && it.due != null }
.mapNotNull { task ->
// A reminder set on the task itself wins; otherwise the task's list
// may override the global lead, or opt out entirely (override =
// null), in which case it gets no reminder at all.
val reminder = perTask[task.taskId]
val lead = reminder?.minutesBefore
?: settings.reminderLeadFor(task.listId)
?: return@mapNotNull null
// A stored reminder says what it counts back from. Ours are always
// before due, but an imported dmfs alarm or another client's can be
// before *start* — firing those off the due date is silently wrong
// for every task whose start and due differ.
val anchor = if (reminder?.fromStart == true) task.start ?: task.due!! else task.due!!
ScheduledReminder(
taskId = task.taskId,
triggerAt = anchor.toEpochMilliseconds() - lead.coerceAtLeast(0) * 60_000L,
)
// The task's list may override the global lead, or opt out entirely
// (override = null), in which case it gets no reminder at all.
val lead = settings.reminderLeadFor(task.listId) ?: return@mapNotNull null
task.taskId to (task.due!!.toEpochMilliseconds() - lead.coerceAtLeast(0) * 60_000L)
}
// The lower bound trails `now` so a reminder missed while the device was
// off still fires once on boot instead of being silently dropped —
// setExactAndAllowWhileIdle delivers a past trigger immediately. Anything
// already armed stays armed (the diff below), so it can't re-fire.
.filter { it.triggerAt in (now - MISSED_GRACE_MS)..horizon }
.toSet()
.toMap()
.filterValues { it in now..horizon }
val previous = store.all()
(previous - desired).forEach { cancel(it) }
(desired - previous).forEach { schedule(it) }
(previous.keys - desired.keys).forEach { cancel(it) }
desired.forEach { (taskId, triggerAt) ->
if (previous[taskId] != triggerAt) schedule(taskId, triggerAt)
}
store.replace(desired)
}
private fun alarmManager(): AlarmManager = context.getSystemService(AlarmManager::class.java)
private fun pendingIntent(reminder: ScheduledReminder, create: Boolean): PendingIntent? {
private fun pendingIntent(taskId: Long, create: Boolean): PendingIntent? {
val flags = (if (create) PendingIntent.FLAG_UPDATE_CURRENT else PendingIntent.FLAG_NO_CREATE) or
PendingIntent.FLAG_IMMUTABLE
return PendingIntent.getBroadcast(
context,
reminder.requestCode,
DueReminderReceiver.intent(context, reminder.taskId, reminder.triggerAt),
flags,
)
return PendingIntent.getBroadcast(context, taskId.toInt(), DueReminderReceiver.intent(context, taskId), flags)
}
private fun schedule(reminder: ScheduledReminder) {
val triggerAt = reminder.triggerAt
val pi = pendingIntent(reminder, create = true) ?: return
private fun schedule(taskId: Long, triggerAt: Long) {
val pi = pendingIntent(taskId, create = true) ?: return
val am = alarmManager()
val canExact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms()
if (canExact) {
@@ -126,21 +81,19 @@ class ReminderScheduler @Inject constructor(
}
}
private fun cancel(reminder: ScheduledReminder) {
pendingIntent(reminder, create = false)?.let {
private fun cancel(taskId: Long) {
pendingIntent(taskId, create = false)?.let {
alarmManager().cancel(it)
it.cancel()
}
}
private suspend fun clearAll() {
store.all().forEach { cancel(it) }
store.replace(emptySet())
store.all().keys.forEach { cancel(it) }
store.replace(emptyMap())
}
private companion object {
const val WINDOW_MS = 30L * 24 * 60 * 60 * 1000 // 30 days
/** How long after its trigger a missed reminder is still worth firing. */
const val MISSED_GRACE_MS = 6L * 60 * 60 * 1000 // 6 hours
}
}

View File

@@ -9,38 +9,25 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
* One armed alarm. A recurring task has many occurrences sharing a [taskId], so
* the trigger time is part of the identity — keying by task alone would collapse
* a daily task down to a single reminder.
*/
data class ScheduledReminder(val taskId: Long, val triggerAt: Long) {
/**
* Request code for this alarm's PendingIntent. Derived from both fields so
* sibling occurrences don't share (and overwrite) one alarm slot.
*/
val requestCode: Int get() = (taskId * 31 + triggerAt).hashCode()
}
/**
* Remembers which task reminders are currently armed, so [ReminderScheduler] can
* diff against a fresh computation and touch only the alarms that changed.
* Persisted in DataStore as a set of `taskId|trigger` strings.
* Remembers which task reminders are currently scheduled (taskId → trigger time),
* so [ReminderScheduler] can diff against a fresh computation and cancel only the
* alarms that changed. Persisted in DataStore as a set of `taskId|trigger` strings.
*/
@Singleton
class ScheduledReminderStore @Inject constructor(
private val dataStore: DataStore<Preferences>,
) {
suspend fun all(): Set<ScheduledReminder> =
suspend fun all(): Map<Long, Long> =
dataStore.data.first()[KEY].orEmpty().mapNotNull { entry ->
val parts = entry.split('|')
val id = parts.getOrNull(0)?.toLongOrNull()
val at = parts.getOrNull(1)?.toLongOrNull()
if (id != null && at != null) ScheduledReminder(id, at) else null
}.toSet()
if (id != null && at != null) id to at else null
}.toMap()
suspend fun replace(scheduled: Set<ScheduledReminder>) {
suspend fun replace(scheduled: Map<Long, Long>) {
dataStore.edit { prefs ->
prefs[KEY] = scheduled.map { "${it.taskId}|${it.triggerAt}" }.toSet()
prefs[KEY] = scheduled.entries.map { "${it.key}|${it.value}" }.toSet()
}
}

View File

@@ -11,16 +11,13 @@ import android.os.Looper
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.export.ExportTask
import java.time.ZoneId
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Instant
/**
* The only class that knows about the ContentResolver, [TasksContract] and the
@@ -86,26 +83,6 @@ class AndroidTasksDataSource @Inject constructor(
} ?: emptyList()
}
override fun exportTasks(listId: Long): List<ExportTask> {
// projection = null for the same reason queryInstances uses it: the tasks
// table's shape varies across provider versions, and the by-name mapper
// reads what's there.
val uri = TasksContract.tasksUri(authority())
return resolver.query(
uri,
null,
// _deleted marks a row awaiting a sync round-trip. It's gone as far as
// the user is concerned, so exporting it would resurrect deleted tasks
// in the backup.
"${Tasks.LIST_ID} = ? AND (${Tasks.DELETED} IS NULL OR ${Tasks.DELETED} = 0)",
arrayOf(listId.toString()),
null,
)?.use { c ->
val reader = CursorColumnReader(c)
buildList { while (c.moveToNext()) add(TaskMapper.exportTask(reader)) }
} ?: emptyList()
}
// --- writes ---------------------------------------------------------------
override fun insertTask(form: TaskForm): Long {
@@ -121,109 +98,12 @@ class AndroidTasksDataSource @Inject constructor(
if (rows == 0) throw TaskWriteFailedException("update task $taskId")
}
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) {
val instanceId = instanceIdFor(taskId, occurrenceStart)
?: throw TaskWriteFailedException("update instance $taskId@$occurrenceStart: no such occurrence")
val values = TaskWriteMapper.instanceValues(form, ZoneId.systemDefault().id)
val uri = TasksContract.instanceUri(authority(), instanceId)
val rows = resolver.update(uri, values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("update instance $instanceId")
}
/**
* The provider's instance row id for one occurrence.
*
* The seam addresses occurrences by `(taskId, occurrenceStart)`; writing
* through the instances URI still needs the row id, so it is looked up here
* rather than carried around above the data layer. Selection is on `task_id`
* only — the anchor is matched in Kotlin because the column that holds it
* (`instance_original_time`) is missing on older provider schemas, where a
* WHERE clause naming it would throw instead of falling back.
*/
private fun instanceIdFor(taskId: Long, occurrenceStart: Instant): Long? {
val uri = TasksContract.instancesUri(authority())
val selection = "${Instances.TASK_ID} = ?"
return resolver.query(uri, null, selection, arrayOf(taskId.toString()), null)?.use { c ->
val reader = CursorColumnReader(c)
while (c.moveToNext()) {
if (TaskMapper.occurrenceAnchor(reader) == occurrenceStart) {
return@use reader.getLong(Tasks.ID)
}
}
null
}
}
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) {
val uri = TasksContract.propertiesUri(authority())
// Replace rather than update: the provider's AlarmHandler re-validates the
// whole row on every update, so a partial edit throws — and delete+insert
// means we never have to track property_id.
resolver.delete(
uri,
"${Properties.TASK_ID} = ? AND ${Properties.MIMETYPE} = ?",
arrayOf(taskId.toString(), TasksContract.Alarm.MIMETYPE),
)
if (minutesBeforeDue != null) {
resolver.insert(uri, TaskWriteMapper.alarmValues(taskId, minutesBeforeDue).toContentValues())
?: throw TaskWriteFailedException("set alarm for task $taskId")
}
}
override fun alarms(): Map<Long, TaskReminder> {
val uri = TasksContract.propertiesUri(authority())
val projection = arrayOf(
Properties.TASK_ID,
TasksContract.Alarm.MINUTES_BEFORE,
TasksContract.Alarm.REFERENCE,
)
return resolver.query(
uri,
projection,
"${Properties.MIMETYPE} = ?",
arrayOf(TasksContract.Alarm.MIMETYPE),
null,
)?.use { c ->
val reader = CursorColumnReader(c)
buildMap {
while (c.moveToNext()) {
val id = reader.getLong(Properties.TASK_ID)
val minutes = reader.getInt(TasksContract.Alarm.MINUTES_BEFORE)
val reference = reader.getInt(TasksContract.Alarm.REFERENCE)
if (id != null && minutes != null) {
put(
id,
TaskReminder(
minutesBefore = minutes,
fromStart = reference == TasksContract.Alarm.REFERENCE_START,
),
)
}
}
}
} ?: emptyMap()
}
override fun setCompleted(taskId: Long, completed: Boolean) {
val values = TaskWriteMapper.completionValues(completed, System.currentTimeMillis())
val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("complete task $taskId")
}
/**
* Through the instances URI, which is what makes the provider fork an override
* rather than close the series. No instance row for the anchor means the task
* is not a series after all — the plain write is then the right one.
*/
override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) {
val instanceId = instanceIdFor(taskId, occurrenceStart)
?: return setCompleted(taskId, completed)
val values = TaskWriteMapper.completionValues(completed, System.currentTimeMillis())
val uri = TasksContract.instanceUri(authority(), instanceId)
val rows = resolver.update(uri, values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("complete instance $instanceId")
}
override fun deleteTask(taskId: Long) {
resolver.delete(taskUri(authority(), taskId), null, null)
}
@@ -240,31 +120,6 @@ class AndroidTasksDataSource @Inject constructor(
return result.lastPathSegment?.toLongOrNull() ?: throw TaskWriteFailedException("create local list: no id")
}
override fun updateList(listId: Long, name: String, color: Int) {
val values = TaskWriteMapper.listValues(name, color)
val rows = resolver.update(listSyncUri(listId), values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("update list $listId")
}
override fun deleteList(listId: Long) {
val rows = resolver.delete(listSyncUri(listId), null, null)
if (rows == 0) throw TaskWriteFailedException("delete list $listId")
}
/**
* A list row addressed as its own account's sync adapter — the provider only
* lets that caller write the `tasklists` table, and the account has to be the
* row's own (the params are matched against it, not merely accepted).
*/
private fun listSyncUri(listId: Long): Uri {
val authority = authority()
val uri = TasksContract.listUri(authority, listId)
val account = resolver.query(uri, arrayOf(Lists.ACCOUNT_NAME, Lists.ACCOUNT_TYPE), null, null, null)
?.use { c -> if (c.moveToFirst()) c.getString(0).orEmpty() to c.getString(1).orEmpty() else null }
?: throw TaskWriteFailedException("list $listId not found")
return TasksContract.asSyncAdapter(uri, account.first, account.second)
}
// --- observation ----------------------------------------------------------
override fun registerObserver(onChange: () -> Unit): AutoCloseable {
@@ -272,16 +127,9 @@ class AndroidTasksDataSource @Inject constructor(
val observer = object : ContentObserver(Handler(Looper.getMainLooper())) {
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.listsUri(provider.authority), true, observer)
} catch (e: RuntimeException) {
runCatching { resolver.unregisterContentObserver(observer) }
throw e
}
return AutoCloseable { runCatching { resolver.unregisterContentObserver(observer) } }
resolver.registerContentObserver(TasksContract.instancesUri(provider.authority), true, observer)
resolver.registerContentObserver(TasksContract.listsUri(provider.authority), true, observer)
return AutoCloseable { resolver.unregisterContentObserver(observer) }
}
private fun Map<String, Any?>.toContentValues(): ContentValues {

View File

@@ -1,88 +0,0 @@
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
}
}
}
}

View File

@@ -1,46 +0,0 @@
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()
}

View File

@@ -1,31 +0,0 @@
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
}

View File

@@ -1,12 +1,15 @@
package de.jeanlucmakiola.agendula.data.tasks
import java.util.concurrent.CopyOnWriteArrayList
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
/**
* An external tasks provider Agendula can talk to. Every candidate runs the same
* dmfs `TaskProvider`, so the [TasksContract] columns apply to either.
* A tasks provider Agendula can talk to. The same dmfs `TaskProvider` backs every
* candidate, so the [TasksContract] columns apply regardless of which is present.
*/
data class TaskProvider(
val authority: String,
@@ -16,122 +19,41 @@ data class TaskProvider(
)
/**
* Discovers the *external* tasks providers (OpenTasks, tasks.org) that
* [StorageMode.EXTERNAL] can be pointed at.
*
* This used to be the A/B seam between an external provider and one Agendula
* bundled itself. That second half is gone: [StorageMode.OWN] is a Room database
* with no authority, no ContentResolver and nothing to permit, so there is
* nothing here for it to resolve. See `docs/OWN-STORE.md`.
*
* Which store is active comes from [storageMode]; how that gets decided when the
* user has not chosen is [autoMode].
* The A/B seam. Detects which tasks provider is installed at runtime and which
* permission set it needs, so nothing above the data layer hardcodes an
* authority. Under Posture B (bundled provider) this simply finds our own
* `org.dmfs.tasks` first. See docs/PLAN.md.
*/
@Singleton
class ProviderResolver @Inject constructor(
private val environment: ProviderEnvironment,
@ApplicationContext private val context: Context,
) {
private val modeListeners = CopyOnWriteArrayList<() -> Unit>()
/**
* The user's explicit choice, or `null` while they have not made one (which is
* the normal state — most people never open Settings). Kept as a plain field
* rather than read from DataStore on demand because [resolve] is called from
* synchronous data-source code on every query, including from the main thread
* via `providerStatus()`. [StorageModeHolder] owns keeping it current.
*
* Assigning a *different* mode notifies [onModeChanged]: every live store
* observer is bound to one store and has to be moved across.
*/
@Volatile
var storageMode: StorageMode? = null
set(value) {
val changed = field != value
field = value
if (changed) modeListeners.forEach { it() }
}
/**
* Observe switches between stores. Fires on the thread that set [storageMode]
* — [StorageModeHolder]'s collector — so listeners must be cheap and must not
* block.
*/
fun onModeChanged(listener: () -> Unit): AutoCloseable {
modeListeners += listener
return AutoCloseable { modeListeners -= listener }
}
/** The active store, resolving the undecided case through [autoMode]. */
fun mode(): StorageMode = storageMode ?: autoMode()
/**
* The provider to query, or `null` — either because [StorageMode.OWN] is
* active and there is no provider involved at all, or because
* [StorageMode.EXTERNAL] is and none is installed. Callers that need to tell
* those apart ask [mode].
*/
fun resolve(): TaskProvider? = when (mode()) {
StorageMode.OWN -> null
StorageMode.EXTERNAL -> resolveExternal()
}
/**
* What to use when the user has not chosen — and the one piece of real
* judgement in this class, because getting it wrong loses people their data.
*
* Ranking our own store first unconditionally would be wrong: someone who
* has been using Agendula over OpenTasks since 0.3.x would update, land on an
* empty database, and reasonably conclude their tasks were deleted.
*
* So the tell is **whether we already hold an external provider's runtime
* permission**. That is a dangerous permission — it can only be there because
* a previous version asked and the user agreed, which is precisely the
* definition of "this person is an existing Posture A user". A fresh install
* never holds it, and gets our own store.
*
* Deliberately cheap and synchronous: a PackageManager lookup and a permission
* check, no database probe. Settings overrides it either way.
*/
fun autoMode(): StorageMode {
val external = resolveExternal()
return if (external != null && hasPermission(external)) StorageMode.EXTERNAL else StorageMode.OWN
}
/** The first installed external candidate, or `null` when none is present. */
fun resolveExternal(): TaskProvider? {
for (candidate in EXTERNAL_CANDIDATES) {
val packageName = environment.packageDeclaring(candidate.authority) ?: continue
return candidate.copy(packageName = packageName)
/** The active provider, or `null` when no tasks provider is installed. */
fun resolve(): TaskProvider? {
for (candidate in CANDIDATES) {
val info = context.packageManager.resolveContentProvider(candidate.authority, 0)
?: continue
return candidate.copy(packageName = info.packageName)
}
return null
}
fun hasPermission(provider: TaskProvider): Boolean =
environment.isGranted(provider.readPermission) && environment.isGranted(provider.writePermission)
granted(provider.readPermission) && granted(provider.writePermission)
/**
* 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
}
private fun granted(permission: String): Boolean =
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
companion object {
/**
* Verified on-device: tasks.org exposes `org.tasks.opentasks` backed by
* `org.dmfs.provider.tasks.TaskProvider`, guarded by `org.tasks.permission.*`
* (dangerous). OpenTasks uses `org.dmfs.tasks` + `org.dmfs.permission.*`.
* OpenTasks is listed first as the canonical authority; on a device with
* only one installed, order is moot.
* `org.dmfs.provider.tasks.TaskProvider`, guarded by
* `org.tasks.permission.*` (dangerous). OpenTasks uses `org.dmfs.tasks`
* + `org.dmfs.permission.*`. OpenTasks is listed first as the canonical
* authority; on a device with only one installed, order is moot.
*/
val EXTERNAL_CANDIDATES: List<TaskProvider> = listOf(
val CANDIDATES: List<TaskProvider> = listOf(
TaskProvider(
authority = "org.dmfs.tasks",
readPermission = "org.dmfs.permission.READ_TASKS",

View File

@@ -1,46 +0,0 @@
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()
}

View File

@@ -1,26 +0,0 @@
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,
}

View File

@@ -1,47 +0,0 @@
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()
}

View File

@@ -5,7 +5,6 @@ import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.export.ExportTask
import de.jeanlucmakiola.agendula.domain.priorityFromICal
import de.jeanlucmakiola.agendula.domain.statusFromInt
import kotlin.time.Instant
@@ -17,17 +16,10 @@ object TaskMapper {
fun instant(name: String): Instant? =
r.getLong(name)?.let { Instant.fromEpochMilliseconds(it) }
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)
val instanceId = r.getLong(Tasks.ID) ?: 0L
return Task(
taskId = r.getLong(Instances.TASK_ID) ?: rowId,
id = instanceId,
taskId = r.getLong(Instances.TASK_ID) ?: instanceId,
listId = r.getLong(Tasks.LIST_ID) ?: 0L,
title = r.getString(Tasks.TITLE).orEmpty(),
description = r.getString(Tasks.DESCRIPTION),
@@ -46,66 +38,13 @@ object TaskMapper {
listName = r.getString(Tasks.LIST_NAME),
accountName = r.getString(Tasks.ACCOUNT_NAME),
parentId = r.getLong(Tasks.PARENT_ID),
isRecurring = recurring,
occurrenceStart = if (recurring) occurrenceAnchor(r) else null,
isRecurring = r.getBoolean(Instances.IS_RECURRING),
distanceFromCurrent = r.getInt(Instances.DISTANCE_FROM_CURRENT),
created = instant(Tasks.CREATED),
lastModified = instant(Tasks.LAST_MODIFIED),
)
}
/**
* The occurrence's `RECURRENCE-ID` anchor.
*
* `instance_original_time` is the provider's own name for it and is set on
* every occurrence of a recurring task, so it is read first. It is absent on
* older provider schemas, where the fallbacks reconstruct the same value: a
* DTSTART-anchored series instantiates each occurrence at its start, and a
* series carrying only DUE anchors on the due date instead.
*/
fun occurrenceAnchor(r: ColumnReader): Instant? =
(
r.getLong(Instances.INSTANCE_ORIGINAL_TIME)
?: r.getLong(Instances.INSTANCE_START)
?: r.getLong(Instances.INSTANCE_DUE)
)?.let { Instant.fromEpochMilliseconds(it) }
/**
* Maps a row of the **`tasks` table** — a master task, not an occurrence.
*
* Export reads there rather than from `instances` on purpose: in the instances
* view a recurring task appears once per occurrence with its times already
* resolved and no rule attached, so exporting from it would write the same
* task many times over and drop the RRULE that produced them. Here each task
* appears exactly once, carrying the rule itself.
*/
fun exportTask(r: ColumnReader): ExportTask {
fun instant(name: String): Instant? =
r.getLong(name)?.let { Instant.fromEpochMilliseconds(it) }
return ExportTask(
taskId = r.getLong(Tasks.ID) ?: 0L,
uid = r.getString(Tasks.UID),
title = r.getString(Tasks.TITLE).orEmpty(),
description = r.getString(Tasks.DESCRIPTION),
location = r.getString(Tasks.LOCATION),
url = r.getString(Tasks.URL),
priority = priorityFromICal(r.getInt(Tasks.PRIORITY)),
status = statusFromInt(r.getInt(Tasks.STATUS)),
percentComplete = r.getInt(Tasks.PERCENT_COMPLETE),
// The task's own columns, not the instance view's resolved ones.
start = instant(Tasks.DTSTART),
due = instant(Tasks.DUE),
isAllDay = r.getBoolean(Tasks.IS_ALLDAY),
completedAt = instant(Tasks.COMPLETED),
created = instant(Tasks.CREATED),
lastModified = instant(Tasks.LAST_MODIFIED),
rrule = r.getString(Tasks.RRULE),
rdate = r.getString(Tasks.RDATE),
parentId = r.getLong(Tasks.PARENT_ID)?.takeIf { it > 0 },
)
}
fun taskList(r: ColumnReader): TaskList = TaskList(
id = r.getLong(Lists.ID) ?: 0L,
name = r.getString(Lists.NAME).orEmpty(),

View File

@@ -1,6 +1,8 @@
package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
/** Column lists requested from the provider. Order is irrelevant; we read by name. */
object TaskProjections {
@@ -16,9 +18,31 @@ object TaskProjections {
Lists.ACCOUNT_TYPE,
)
// No `instances` projection on purpose: that read passes `projection = null`
// (all columns), because the view's shape differs across provider versions —
// tasks.org's bundled OpenTasks has no `is_recurring`, for one. A fixed list
// here would drift out of sync with the by-name mapper and quietly drop
// columns it depends on. See AndroidTasksDataSource.queryInstances.
/** Read from the `instances` view (inherits all task columns). */
val INSTANCES: Array<String> = arrayOf(
Tasks.ID,
Instances.TASK_ID,
Tasks.LIST_ID,
Tasks.TITLE,
Tasks.DESCRIPTION,
Tasks.LOCATION,
Tasks.URL,
Tasks.PRIORITY,
Tasks.STATUS,
Tasks.PERCENT_COMPLETE,
Tasks.COMPLETED,
Tasks.IS_ALLDAY,
Tasks.TZ,
Instances.INSTANCE_START,
Instances.INSTANCE_DUE,
Tasks.TASK_COLOR,
Tasks.LIST_COLOR,
Tasks.LIST_NAME,
Tasks.ACCOUNT_NAME,
Tasks.PARENT_ID,
Instances.IS_RECURRING,
Instances.DISTANCE_FROM_CURRENT,
Tasks.CREATED,
Tasks.LAST_MODIFIED,
)
}

View File

@@ -1,21 +1,9 @@
package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Alarm
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.toICal
import kotlin.time.Instant
private const val MILLIS_PER_DAY = 24L * 60 * 60 * 1000
/** Floor to UTC midnight when [allDay], else pass through unchanged. */
private fun Instant.forAllDay(allDay: Boolean): Instant =
if (!allDay) this
else Instant.fromEpochMilliseconds(
Math.floorDiv(toEpochMilliseconds(), MILLIS_PER_DAY) * MILLIS_PER_DAY,
)
/**
* Turns a [TaskForm] / mutation into a name→value map. Pure (no ContentValues),
@@ -51,17 +39,8 @@ object TaskWriteMapper {
}
}
put(Tasks.IS_ALLDAY, if (form.isAllDay) 1 else 0)
// All-day tasks are date-only in iCalendar. The provider reads them back
// through DateTime.toAllDay(), which drops the time-of-day and resolves the
// remaining date against UTC — so a local-midnight instant lands on the
// previous day for anyone west of UTC. Pin all-day values to UTC midnight.
put(Tasks.DTSTART, form.start?.forAllDay(form.isAllDay)?.toEpochMilliseconds())
put(Tasks.DUE, form.due?.forAllDay(form.isAllDay)?.toEpochMilliseconds())
// DUE and DURATION are mutually exclusive. The provider's Validating
// processor evaluates the *merged* row (supplied values over the stored
// ones), so writing DUE onto a task that already carries a DURATION throws
// "Only one of DUE or DURATION must be supplied." Clear it alongside.
put(Tasks.DURATION, null)
put(Tasks.DTSTART, form.start?.toEpochMilliseconds())
put(Tasks.DUE, form.due?.toEpochMilliseconds())
put(Tasks.PARENT_ID, form.parentId)
// The provider treats a null tz as local time; set it explicitly for
// timed tasks so the stored instant is unambiguous across zones.
@@ -69,16 +48,6 @@ object TaskWriteMapper {
put(Tasks.TZ, if (timed) tzId else null)
}
/**
* Values for an update through the *instances* URI (a recurring occurrence).
* The provider clones the row into an override and strips list/recurrence
* fields as it goes, so LIST_ID and PARENT_ID are dropped here rather than
* written and silently ignored — moving one occurrence between lists or
* parents isn't a thing the override model expresses.
*/
fun instanceValues(form: TaskForm, tzId: String): Map<String, Any?> =
taskValues(form, tzId) - Tasks.LIST_ID - Tasks.PARENT_ID
fun completionValues(completed: Boolean, nowMillis: Long): Map<String, Any?> =
if (completed) {
mapOf(
@@ -94,26 +63,9 @@ object TaskWriteMapper {
)
}
/**
* 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(
fun localListValues(name: String, color: Int): Map<String, Any?> = mapOf(
Lists.NAME to name.trim(),
Lists.COLOR to color,
)
fun localListValues(name: String, color: Int): Map<String, Any?> = listValues(name, color) + mapOf(
Lists.ACCOUNT_NAME to TasksContract.LOCAL_ACCOUNT_NAME,
Lists.ACCOUNT_TYPE to TasksContract.LOCAL_ACCOUNT_TYPE,
Lists.VISIBLE to 1,

View File

@@ -66,9 +66,6 @@ object TasksContract {
const val IS_ALLDAY = "is_allday"
const val TZ = "tz"
const val RRULE = "rrule"
const val RDATE = "rdate"
/** Set on an override row — the master occurrence this one replaces. */
const val ORIGINAL_INSTANCE_ID = "original_instance_id"
const val PARENT_ID = "parent_id"
const val SORTING = "sorting"
const val CREATED = "created"
@@ -99,59 +96,8 @@ object TasksContract {
const val INSTANCE_DUE_SORTING = "instance_due_sorting"
const val DISTANCE_FROM_CURRENT = "distance_from_current"
const val IS_RECURRING = "is_recurring"
/**
* The occurrence's `RECURRENCE-ID` — the time this occurrence was
* instantiated at, before any override moved it. Set on every occurrence
* of a recurring task, which is what makes it the occurrence's identity.
*/
const val INSTANCE_ORIGINAL_TIME = "instance_original_time"
}
/** The `properties` table — per-task side rows, discriminated by [Properties.MIMETYPE]. */
object Properties {
const val PATH = "properties"
const val PROPERTY_ID = "property_id"
const val TASK_ID = "task_id"
const val MIMETYPE = "mimetype"
}
/**
* An alarm property row — a per-task reminder lead.
*
* Storage and sync format *only*: the provider fires nothing (its alarm
* scheduling is commented out and the internal `alarms` table is never
* populated), so [de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler]
* still arms the real AlarmManager alarm. Writing it here is what makes the
* lead survive a sync and show up in other OpenTasks clients.
*
* The columns are the generic `dataN` slots; the meanings below are the
* Alarm property's contract for them.
*/
object Alarm {
const val MIMETYPE = "vnd.android.cursor.item/alarm"
/** `data0` — minutes from the reference date; positive means *before* it. */
const val MINUTES_BEFORE = "data0"
/** `data1` — which date to count from. */
const val REFERENCE = "data1"
/** `data2` — optional message shown with the alarm. */
const val MESSAGE = "data2"
/** `data3` — alarm kind. Must be present, and non-zero to count as an alarm. */
const val ALARM_TYPE = "data3"
const val REFERENCE_DUE = 1
const val REFERENCE_START = 2
/** 0 (NOTHING) is excluded from the provider's `has_alarms` count — use MESSAGE. */
const val TYPE_MESSAGE = 1
}
fun propertiesUri(authority: String): Uri = Uri.parse("content://$authority/${Properties.PATH}")
// --- status values (TaskColumns.STATUS_*) --------------------------------
const val STATUS_NEEDS_ACTION = 0
const val STATUS_IN_PROCESS = 1
@@ -163,20 +109,9 @@ object TasksContract {
fun authorityUri(authority: String): Uri = Uri.parse("content://$authority")
fun listsUri(authority: String): Uri = Uri.parse("content://$authority/${Lists.PATH}")
fun listUri(authority: String, listId: Long): Uri =
Uri.parse("content://$authority/${Lists.PATH}/$listId")
fun tasksUri(authority: String): Uri = Uri.parse("content://$authority/${Tasks.PATH}")
fun instancesUri(authority: String): Uri = Uri.parse("content://$authority/${Instances.PATH}")
/**
* A single occurrence. Updating through this URI is how a *recurring* task is
* edited: the provider clones the row into an override task
* (`original_instance_id` set, recurrence fields stripped) instead of moving
* the series anchor, which is what writing to `tasks/<id>` would do.
*/
fun instanceUri(authority: String, instanceId: Long): Uri =
Uri.parse("content://$authority/${Instances.PATH}/$instanceId")
/** Append the sync-adapter params required to write local-account rows. */
fun asSyncAdapter(uri: Uri, accountName: String, accountType: String): Uri =
uri.buildUpon()

View File

@@ -3,17 +3,6 @@ package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList
import kotlin.time.Instant
/**
* A stored reminder: how long before, and what it counts back from.
*
* [fromStart] matters because both stores can hold a `START`-referenced alarm —
* the dmfs import preserves one, and an external provider's other clients write
* them — while Agendula's own UI only ever sets a before-due lead. Collapsing it
* to a number here is what silently fired those reminders off the wrong anchor.
*/
data class TaskReminder(val minutesBefore: Int, val fromStart: Boolean = false)
/** What to fetch from the provider. Smart-list date logic is applied above this. */
data class TaskQuery(
@@ -34,67 +23,10 @@ interface TasksDataSource {
fun insertTask(form: TaskForm): Long
fun updateTask(taskId: Long, form: TaskForm)
/**
* Update a single occurrence of a recurring task, addressed by the task row and
* the occurrence's `RECURRENCE-ID` anchor ([Task.occurrenceStart]). The store
* forks an override rather than moving the series anchor — which is what
* [updateTask] would do, since a recurring task's start/due are the
* occurrence's resolved times.
*
* Addressing by `(taskId, occurrenceStart)` rather than by a materialised
* instance row id keeps this seam independent of any one store's row
* numbering; External mode maps it back to an instance row itself.
*/
fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm)
/**
* Set (or clear, with `null`) the per-task reminder lead, stored as an Alarm
* property row. The provider never fires it — [de.jeanlucmakiola.agendula
* .data.reminders.ReminderScheduler] does — but persisting it here is what
* syncs the lead and shares it with other OpenTasks clients.
*/
fun setAlarm(taskId: Long, minutesBeforeDue: Int?)
/** Every task's reminder, by task id. One query, for the scheduler. */
fun alarms(): Map<Long, TaskReminder>
/**
* Every task in [listId] read from the **`tasks` table**, for export. Masters,
* not occurrences — see [TaskMapper.exportTask] for why that distinction
* matters. Excludes rows the provider has flagged deleted-but-unsynced.
*/
fun exportTasks(listId: Long): List<de.jeanlucmakiola.agendula.domain.export.ExportTask>
fun setCompleted(taskId: Long, completed: Boolean)
/**
* Complete (or reopen) **one occurrence** of a recurring task, addressed the
* same way [updateInstance] is. Ticking a series through [setCompleted] would
* close the master row, which takes every past and future occurrence out of
* every list at once.
*
* Implementations fall back to [setCompleted] when the row turns out not to
* be a series master — an override, or a plain task the caller happened to
* hand an anchor for — so the routing above cannot get this wrong.
*/
fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean)
fun deleteTask(taskId: Long)
fun createLocalList(name: String, color: Int): Long
/** Rename and recolour [listId]. */
fun updateList(listId: Long, name: String, color: Int)
/**
* Delete [listId] **and the tasks in it** — `tasks.list_id` cascades on the
* Room path, and the provider does the same on the External one.
*
* Only ever called for a local, device-only list: a collection that belongs
* to an account is the server's to remove, and neither store expresses a
* collection tombstone yet. The UI gates on [TaskList.isLocal]; this seam
* does not re-check it.
*/
fun deleteList(listId: Long)
/** Observe any change to tasks/lists; [onChange] fires on a background thread. */
fun registerObserver(onChange: () -> Unit): AutoCloseable
}

View File

@@ -37,25 +37,9 @@ interface TasksRepository {
* since the form loaded. Pass `null` to force the write (overwrite-anyway).
*/
suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant? = null)
/**
* 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 setCompleted(taskId: Long, completed: Boolean)
suspend fun deleteTask(taskId: Long)
/**
* The per-task reminder lead in minutes before due, or `null` if the task has
* none (in which case the list's / global setting applies). Read when the edit
* form loads so saving can't silently drop it.
*/
suspend fun reminderFor(taskId: Long): Int?
suspend fun createLocalList(name: String, color: Int): Long
suspend fun updateList(listId: Long, name: String, color: Int)
/** Deletes the list **and its tasks**. Local lists only — see [TasksDataSource.deleteList]. */
suspend fun deleteList(listId: Long)
/** Synchronous snapshot for the permission/onboarding gate. */
fun providerStatus(): ProviderStatus

View File

@@ -28,7 +28,6 @@ import kotlin.time.Instant
class TasksRepositoryImpl @Inject constructor(
private val dataSource: TasksDataSource,
private val providerResolver: ProviderResolver,
private val startupGate: StartupGate,
@IoDispatcher private val io: CoroutineDispatcher,
) : TasksRepository {
@@ -81,50 +80,22 @@ class TasksRepositoryImpl @Inject constructor(
}
override suspend fun createTask(form: TaskForm): Long =
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() }
withContext(io) { dataSource.insertTask(form) }
override suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant?) =
withContext(io) {
// Re-read just before writing: it settles the conflict check *and* tells
// us which URI to write through.
val current = dataSource.task(taskId)
// Conflict-safe overwrite: bail if the provider's last_modified moved
// since the form captured it (external sync / another app). A null
// baseline means "force / overwrite anyway".
// Conflict-safe overwrite: re-read just before writing and bail if the
// provider's last_modified moved since the form captured it (external
// sync / another app). A null baseline means "force / overwrite anyway".
if (expectedLastModified != null) {
val seen = current?.lastModified
if (seen != null && seen != expectedLastModified) throw TaskConflictException(taskId)
}
// Write the reminder first: forking a recurring occurrence copies the
// task's properties onto the new override row, so setting the alarm
// beforehand is what carries it across.
dataSource.setAlarm(taskId, form.reminderMinutesBeforeDue)
// A recurring task's start/due are one occurrence's resolved times, so
// writing them back to the task row would re-anchor the whole series.
// Going through the occurrence forks an override instead.
val occurrence = current?.takeIf { it.isRecurring }?.occurrenceStart
if (occurrence != null) {
dataSource.updateInstance(taskId, occurrence, form)
} else {
dataSource.updateTask(taskId, form)
val current = dataSource.task(taskId)?.lastModified
if (current != null && current != expectedLastModified) throw TaskConflictException(taskId)
}
dataSource.updateTask(taskId, form)
}
override suspend fun setCompleted(taskId: Long, occurrenceStart: Instant?, completed: Boolean) =
withContext(io) {
if (occurrenceStart != null) {
dataSource.setCompletedInstance(taskId, occurrenceStart, completed)
} else {
dataSource.setCompleted(taskId, completed)
}
}
override suspend fun setCompleted(taskId: Long, completed: Boolean) =
withContext(io) { dataSource.setCompleted(taskId, completed) }
override suspend fun deleteTask(taskId: Long) =
withContext(io) { dataSource.deleteTask(taskId) }
@@ -132,32 +103,18 @@ class TasksRepositoryImpl @Inject constructor(
override suspend fun createLocalList(name: String, color: Int): Long =
withContext(io) { dataSource.createLocalList(name, color) }
override suspend fun updateList(listId: Long, name: String, color: Int) =
withContext(io) { dataSource.updateList(listId, name, color) }
override suspend fun deleteList(listId: Long) =
withContext(io) { dataSource.deleteList(listId) }
override fun providerStatus(): ProviderStatus {
// Our own store is always ready: it ships with the app, needs no provider
// and no grant. The permission gate only ever applied to External mode —
// now that is visibly true rather than a special case inside it.
if (providerResolver.mode() == StorageMode.OWN) return ProviderStatus.READY
val provider = providerResolver.resolve() ?: return ProviderStatus.NO_PROVIDER
return if (providerResolver.hasPermission(provider)) ProviderStatus.READY
else ProviderStatus.NEEDS_PERMISSION
}
/**
* Emits an initial load, then re-loads on every store change. The observer
* Emits an initial load, then re-loads on every provider change. The observer
* callback (main thread) only pokes a conflated channel; the actual blocking
* query runs on [io].
*/
private fun <T> observing(load: () -> T): Flow<T> = callbackFlow {
// Nothing reads a store before the stored mode has landed and a v0.3.x
// install has been imported — otherwise the first emission comes from the
// wrong store, or from an empty one.
startupGate.awaitReady()
val ticks = Channel<Unit>(Channel.CONFLATED)
val handle = dataSource.registerObserver { ticks.trySend(Unit) }
ticks.trySend(Unit) // prime the initial emission

View File

@@ -1,384 +0,0 @@
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 18 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 26 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?,
)

View File

@@ -1,30 +0,0 @@
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
}

View File

@@ -1,38 +0,0 @@
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)
}

View File

@@ -1,35 +0,0 @@
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()
}
}
}
}

View File

@@ -1,213 +0,0 @@
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 14 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,
)

View File

@@ -1,26 +0,0 @@
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?,
)

View File

@@ -1,114 +0,0 @@
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,
)
}

View File

@@ -1,282 +0,0 @@
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
}

View File

@@ -1,35 +0,0 @@
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)) }
}
}

View File

@@ -1,134 +0,0 @@
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
}

View File

@@ -1,91 +0,0 @@
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
}
}

View File

@@ -1,51 +0,0 @@
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)
}

View File

@@ -1,34 +0,0 @@
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"
}
}

View File

@@ -1,42 +0,0 @@
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(),
)

View File

@@ -1,5 +1,6 @@
package de.jeanlucmakiola.agendula.domain
import de.jeanlucmakiola.agendula.data.tasks.TasksContract
import kotlin.time.Instant
/** A task list (the `tasklists` table). Lists group under their account. */
@@ -14,8 +15,7 @@ data class TaskList(
val owner: String?,
) {
/** A device-only list Agendula (or another app) created locally, not synced. */
val isLocal: Boolean
get() = accountType == LocalAccount.TYPE || accountType == LocalAccount.DMFS_TYPE
val isLocal: Boolean get() = accountType == TasksContract.LOCAL_ACCOUNT_TYPE
}
enum class TaskStatus { NEEDS_ACTION, IN_PROCESS, COMPLETED, CANCELLED }
@@ -24,11 +24,11 @@ enum class TaskStatus { NEEDS_ACTION, IN_PROCESS, COMPLETED, CANCELLED }
enum class Priority { NONE, LOW, MEDIUM, HIGH }
/**
* One occurrence of a task. [taskId] is the underlying task row and the stable
* target for edits and navigation; [occurrenceStart] distinguishes occurrences of
* the same series.
* A task occurrence as read from the `instances` view. [id] is the instance row
* id; [taskId] is the underlying `tasks._id` and the stable target for edits.
*/
data class Task(
val id: Long,
val taskId: Long,
val listId: Long,
val title: String,
@@ -48,22 +48,7 @@ data class Task(
val listName: String?,
val accountName: String?,
val parentId: Long?,
/**
* This row carries a recurrence rule, so it is one occurrence of a series and
* [start]/[due] are that occurrence's resolved times — *not* the master's
* anchor. Edits go through
* [de.jeanlucmakiola.agendula.data.tasks.TasksDataSource.updateInstance], which
* forks a `RECURRENCE-ID` override instead of re-anchoring the series.
*/
val isRecurring: Boolean,
/**
* This occurrence's `RECURRENCE-ID` anchor — what identifies it within its
* series — or `null` when the task does not recur. Together with [taskId] it
* is a stable, collision-free identity for an occurrence, which is what list
* keys and [de.jeanlucmakiola.agendula.data.tasks.TasksDataSource.updateInstance]
* address it by.
*/
val occurrenceStart: Instant? = null,
val distanceFromCurrent: Int?,
val created: Instant?,
val lastModified: Instant?,
@@ -80,15 +65,6 @@ data class Task(
val isSubtask: Boolean get() = parentId != null && parentId > 0
/** The task's own colour if set, else the list colour. */
val effectiveColor: Int get() = taskColor ?: listColor
/**
* Stable identity for a lazy-list key. Two occurrences of one series can show
* up in the same list, so [taskId] alone is not unique — and folding
* `(taskId, occurrenceStart)` into a Long could collide, which as a Compose
* key is a visible bug.
*/
val occurrenceKey: String
get() = if (occurrenceStart == null) "$taskId" else "$taskId@${occurrenceStart.toEpochMilliseconds()}"
}
/** Detail bundle: a task, its parent (if it's a subtask), and its direct children. */
@@ -109,22 +85,22 @@ fun priorityFromICal(value: Int?): Priority = when {
/** Representative iCalendar priority for a bucket (1 high, 5 medium, 9 low). */
fun Priority.toICal(): Int = when (this) {
Priority.NONE -> PRIORITY_NONE
Priority.NONE -> TasksContract.PRIORITY_NONE
Priority.HIGH -> 1
Priority.MEDIUM -> 5
Priority.LOW -> 9
}
fun statusFromInt(value: Int?): TaskStatus = when (value) {
ICalStatus.IN_PROCESS -> TaskStatus.IN_PROCESS
ICalStatus.COMPLETED -> TaskStatus.COMPLETED
ICalStatus.CANCELLED -> TaskStatus.CANCELLED
TasksContract.STATUS_IN_PROCESS -> TaskStatus.IN_PROCESS
TasksContract.STATUS_COMPLETED -> TaskStatus.COMPLETED
TasksContract.STATUS_CANCELLED -> TaskStatus.CANCELLED
else -> TaskStatus.NEEDS_ACTION
}
fun TaskStatus.toInt(): Int = when (this) {
TaskStatus.NEEDS_ACTION -> ICalStatus.NEEDS_ACTION
TaskStatus.IN_PROCESS -> ICalStatus.IN_PROCESS
TaskStatus.COMPLETED -> ICalStatus.COMPLETED
TaskStatus.CANCELLED -> ICalStatus.CANCELLED
TaskStatus.NEEDS_ACTION -> TasksContract.STATUS_NEEDS_ACTION
TaskStatus.IN_PROCESS -> TasksContract.STATUS_IN_PROCESS
TaskStatus.COMPLETED -> TasksContract.STATUS_COMPLETED
TaskStatus.CANCELLED -> TasksContract.STATUS_CANCELLED
}

View File

@@ -1,30 +0,0 @@
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"
}

View File

@@ -1,66 +0,0 @@
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()
}

View File

@@ -1,204 +0,0 @@
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"
}

View File

@@ -1,179 +0,0 @@
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 }
}

View File

@@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
@@ -20,7 +19,6 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.ui.common.OnResume
import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus
import de.jeanlucmakiola.agendula.ui.navigation.AgendulaNavHost
import de.jeanlucmakiola.agendula.ui.permission.PermissionViewModel
@@ -41,23 +39,11 @@ fun RootScreen(
ActivityResultContracts.RequestMultiplePermissions(),
) { permissionViewModel.refresh() }
// Re-check on every resume, not just after the in-app request: the user may
// have granted the permission (or installed a provider) in system Settings and
// come back, and otherwise the gate would hold until the process restarts.
OnResume { permissionViewModel.refresh() }
// Neither gate can show in OWN mode (that store is always READY), so the way
// out of one is always our own store. Without it a user whose provider app
// went away is held on this screen with Settings behind it.
val fallback = stringResource(R.string.onboarding_use_own_store)
when (permission.status) {
ProviderStatus.NO_PROVIDER -> Gate(
modifier = modifier,
title = stringResource(R.string.onboarding_no_provider_title),
body = stringResource(R.string.onboarding_no_provider_body),
secondaryAction = fallback,
onSecondaryAction = permissionViewModel::useOwnStore,
)
ProviderStatus.NEEDS_PERMISSION -> Gate(
modifier = modifier,
@@ -65,8 +51,6 @@ fun RootScreen(
body = stringResource(R.string.onboarding_permission_body),
action = stringResource(R.string.onboarding_permission_button),
onAction = { launcher.launch(permission.permissionsToRequest.toTypedArray()) },
secondaryAction = fallback,
onSecondaryAction = permissionViewModel::useOwnStore,
)
ProviderStatus.READY -> ReadyGate(modifier = modifier)
}
@@ -103,8 +87,6 @@ private fun Gate(
modifier: Modifier = Modifier,
action: String? = null,
onAction: () -> Unit = {},
secondaryAction: String? = null,
onSecondaryAction: () -> Unit = {},
) {
Column(
modifier = modifier.fillMaxSize().padding(24.dp),
@@ -114,6 +96,5 @@ private fun Gate(
Text(title, style = MaterialTheme.typography.headlineSmall)
Text(body, style = MaterialTheme.typography.bodyMedium)
if (action != null) Button(onClick = onAction) { Text(action) }
if (secondaryAction != null) TextButton(onClick = onSecondaryAction) { Text(secondaryAction) }
}
}

View File

@@ -0,0 +1,167 @@
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)) }
}
}
}
}
}
}

View File

@@ -1,29 +0,0 @@
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()

View File

@@ -1,29 +0,0 @@
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) }
}
}

View File

@@ -1,20 +0,0 @@
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())

View File

@@ -98,7 +98,4 @@ object ActionShapes {
/** Search — a 6-sided cookie, the same family as [Settings] but distinct. */
val Search: RoundedPolygon get() = MaterialShapes.Cookie6Sided
/** New list — a sunny burst beside the Lists header. */
val AddList: RoundedPolygon get() = MaterialShapes.Sunny
}

View File

@@ -4,7 +4,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskDetail
import de.jeanlucmakiola.agendula.domain.TaskForm
@@ -12,6 +11,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
@@ -42,14 +42,14 @@ class TaskDetailViewModel @Inject constructor(
if (detail == null) TaskDetailUiState.NotFound else TaskDetailUiState.Content(detail)
}
.onStart { emit(TaskDetailUiState.Loading) }
.recoveringFromProviderFailure { TaskDetailUiState.NotFound }
.catch { emit(TaskDetailUiState.NotFound) }
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskDetailUiState.Loading)
fun bind(id: Long) { taskId.value = id }
fun toggleComplete(task: Task) = viewModelScope.launch {
runCatching { repository.setCompleted(task.taskId, task.occurrenceStart, !task.isCompleted) }
runCatching { repository.setCompleted(task.taskId, !task.isCompleted) }
}
fun delete(task: Task) = viewModelScope.launch {

View File

@@ -99,8 +99,7 @@ import de.jeanlucmakiola.floret.time.formatTime
import de.jeanlucmakiola.agendula.ui.common.localToInstant
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.agendula.domain.allDayInstantOf
import de.jeanlucmakiola.agendula.domain.calendarDate
import de.jeanlucmakiola.agendula.ui.common.toLocalDate
import de.jeanlucmakiola.agendula.ui.common.toLocalTime
import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel
import java.time.LocalTime
@@ -179,7 +178,7 @@ private fun EditContent(
val accent = selectedList?.let { pastelize(it.color, dark) } ?: MaterialTheme.colorScheme.primary
val gap = 12.dp
var pickerTarget by rememberSaveable { mutableStateOf<PickerTarget?>(null) }
var pickerTarget by remember { mutableStateOf<PickerTarget?>(null) }
var showListPicker by rememberSaveable { mutableStateOf(false) }
var showParentPicker by rememberSaveable { mutableStateOf(false) }
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
@@ -690,15 +689,12 @@ private fun DateTimePickerFlow(
onResult: (Instant) -> Unit,
onDismiss: () -> Unit,
) {
var pendingDate by rememberSaveable { mutableStateOf<java.time.LocalDate?>(null) }
var showTime by rememberSaveable { mutableStateOf(false) }
var pendingDate by remember { mutableStateOf<java.time.LocalDate?>(null) }
var showTime by remember { mutableStateOf(false) }
if (!showTime) {
// M3's DatePicker speaks UTC millis. An all-day value is already UTC-based,
// a timed one is read in the device zone — calendarDate picks the right frame
// so the dialog opens on the day the rest of the UI shows.
val initialMillis = (initial ?: nowInstant())
.calendarDate(allDay).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
.toLocalDate().atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
val dateState = rememberDatePickerState(initialSelectedDateMillis = initialMillis)
DatePickerDialog(
onDismissRequest = onDismiss,
@@ -707,7 +703,7 @@ private fun DateTimePickerFlow(
val millis = dateState.selectedDateMillis ?: run { onDismiss(); return@TextButton }
val date = java.time.Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC).toLocalDate()
if (allDay) {
onResult(allDayInstantOf(date))
onResult(localToInstant(date, LocalTime.MIDNIGHT))
} else {
pendingDate = date
showTime = true

View File

@@ -15,7 +15,6 @@ import de.jeanlucmakiola.agendula.domain.TaskFormError
import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.populatedFields
import de.jeanlucmakiola.agendula.domain.rebasedForAllDay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -71,15 +70,6 @@ class TaskEditViewModel @Inject constructor(
private var editingTaskId: Long? = null
/**
* Whether the form has already been populated. The host `LaunchedEffect`
* re-fires whenever the composition restarts — an Activity recreation
* (rotation, theme/font/display-size change, split-screen, unfolding) — while
* this ViewModel survives on the nav back stack. Without this guard the
* rebind would overwrite in-progress edits with the untouched provider row.
*/
private var bound = false
/** `last_modified` captured when the form loaded — the conflict-check baseline. */
private var baselineLastModified: Instant? = null
@@ -88,8 +78,6 @@ class TaskEditViewModel @Inject constructor(
/** Start a fresh task, optionally pre-selecting a list / parent. */
fun bindNew(presetListId: Long? = null, parentId: Long? = null) {
if (bound) return
bound = true
editingTaskId = null
baselineLastModified = null
viewModelScope.launch {
@@ -115,8 +103,6 @@ class TaskEditViewModel @Inject constructor(
/** Load an existing task for editing. */
fun bindEdit(taskId: Long) {
if (bound && editingTaskId == taskId) return
bound = true
editingTaskId = taskId
viewModelScope.launch {
defaultFields = settingsPrefs.settings.first().defaultEditFields
@@ -140,7 +126,6 @@ class TaskEditViewModel @Inject constructor(
priority = task.priority,
parentId = task.parentId,
percentComplete = task.percentComplete,
reminderMinutesBeforeDue = repository.reminderFor(taskId),
lists = lists,
parentCandidates = loadParents(task.listId, selfId = taskId),
),
@@ -193,19 +178,7 @@ class TaskEditViewModel @Inject constructor(
fun onStartChange(value: Instant?) = update { it.copy(start = value) }
fun onDueChange(value: Instant?) = update { it.copy(due = 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 onAllDayChange(value: Boolean) = update { it.copy(isAllDay = value) }
fun onPriorityChange(value: Priority) = update { it.copy(priority = value) }
fun onPercentChange(value: Int?) = update { it.copy(percentComplete = value?.coerceIn(0, 100)) }
fun onParentChange(parentId: Long?) = update { it.copy(parentId = parentId) }

View File

@@ -1,182 +0,0 @@
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),
)
}

View File

@@ -1,122 +0,0 @@
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
}
}
}
}

View File

@@ -1,295 +0,0 @@
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
}

View File

@@ -46,7 +46,6 @@ import androidx.compose.material.icons.rounded.Upcoming
import androidx.compose.material3.CircularWavyProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -81,10 +80,6 @@ import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.ui.common.ActionShapes
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.SnackChip
import de.jeanlucmakiola.floret.components.SnackChipHeight
import de.jeanlucmakiola.floret.components.SnackChipMargin
import kotlinx.coroutines.delay
import de.jeanlucmakiola.agendula.ui.common.ListColorChip
import de.jeanlucmakiola.agendula.ui.common.ShapedActionButton
import de.jeanlucmakiola.agendula.ui.common.priorityAccent
@@ -113,22 +108,10 @@ fun ListsScreen(
val state by viewModel.state.collectAsStateWithLifecycle()
var query by rememberSaveable { mutableStateOf("") }
var searchActive by rememberSaveable { mutableStateOf(false) }
var newList by rememberSaveable { mutableStateOf(false) }
val closeSearch = {
query = ""
searchActive = false
}
// With no lists there is nowhere to put a task, so the primary action becomes
// making one — otherwise a fresh install's FAB opens a form that cannot save.
val noLists = (state as? ListsUiState.Content)?.groups?.isEmpty() == true
// The sheet closes on save, so a refused write has to report itself here.
val writeFailure by viewModel.writeFailure.collectAsStateWithLifecycle()
LaunchedEffect(writeFailure) {
if (writeFailure != null) {
delay(4_000)
viewModel.clearWriteFailure()
}
}
// System back closes search before leaving the screen.
BackHandler(enabled = searchActive, onBack = closeSearch)
@@ -147,11 +130,9 @@ fun ListsScreen(
// The FAB would otherwise float over the search results.
if (!searchActive) {
ExtendedFloatingActionButton(
onClick = if (noLists) ({ newList = true }) else onNewTask,
onClick = onNewTask,
icon = { Icon(Icons.Rounded.Add, contentDescription = null) },
text = {
Text(stringResource(if (noLists) R.string.list_add else R.string.new_task))
},
text = { Text(stringResource(R.string.new_task)) },
)
}
},
@@ -166,7 +147,6 @@ fun ListsScreen(
state = s,
onOpenFilter = onOpenFilter,
onOpenTask = onOpenTask,
onNewList = { newList = true },
topPadding = 0.dp,
bottomPadding = inner.calculateBottomPadding() + 96.dp,
)
@@ -186,33 +166,8 @@ 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
@@ -220,7 +175,6 @@ private fun ListsContent(
state: ListsUiState.Content,
onOpenFilter: (TaskFilter) -> Unit,
onOpenTask: (Long) -> Unit,
onNewList: () -> Unit,
topPadding: androidx.compose.ui.unit.Dp,
bottomPadding: androidx.compose.ui.unit.Dp,
) {
@@ -261,21 +215,9 @@ private fun ListsContent(
}
if (state.groups.isEmpty()) {
item { EmptyLists(onNewList = onNewList) }
item { CenteredMessage(stringResource(R.string.lists_empty), PaddingValues(top = 24.dp)) }
} else {
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,
)
},
)
}
item { SectionHeader(stringResource(R.string.lists_header)) }
state.groups.forEach { group ->
item(key = "acct-${group.accountName}") { AccountHeader(group.accountName) }
itemsIndexed(group.lists, key = { _, o -> o.list.id }) { index, overview ->
@@ -301,28 +243,6 @@ private fun ListsContent(
}
}
/** No lists at all — a fresh install, where nothing else on this screen works yet. */
@Composable
private fun EmptyLists(onNewList: () -> Unit) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 32.dp, vertical = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = stringResource(R.string.lists_empty),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
FilledTonalButton(onClick = onNewList) {
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.lists_empty_action))
}
}
}
/**
* The home top bar. There is no title — the launcher icon already says which app
* this is. Settings is pinned at the right; the search action sits just left of it
@@ -499,7 +419,7 @@ private fun SearchResults(
}
} else {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(results, key = { it.occurrenceKey }) { task ->
items(results, key = { it.id }) { task ->
UpcomingRow(task = task, onClick = { onOpenTask(task.taskId) })
}
}
@@ -735,25 +655,12 @@ private fun SmartCard(count: SmartCount, modifier: Modifier = Modifier, onClick:
}
@Composable
private fun SectionHeader(text: String, action: (@Composable () -> Unit)? = null) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
start = 28.dp,
end = if (action == null) 28.dp else 20.dp,
top = if (action == null) 16.dp else 12.dp,
bottom = 4.dp,
),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = text,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
action?.invoke()
}
private fun SectionHeader(text: String) {
Text(
text = text,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 4.dp),
)
}
@Composable

View File

@@ -4,30 +4,21 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
import de.jeanlucmakiola.floret.time.DayWindow
import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskFiltering
import de.jeanlucmakiola.agendula.domain.TaskList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.time.ZoneId
import javax.inject.Inject
import kotlin.time.Clock
/**
* What a list write failed at. The screens turn this into wording; the view
* models stay free of resources.
*/
enum class ListWriteFailure { SAVE, DELETE }
data class ListOverview(val list: TaskList, val openCount: Int)
data class AccountGroup(val accountName: String, val lists: List<ListOverview>)
data class SmartCount(val smart: SmartList, val count: Int)
@@ -53,7 +44,7 @@ private const val UPCOMING_PREVIEW = 3
/** The home overview: smart lists with live counts, then user lists by account. */
@HiltViewModel
class ListsViewModel @Inject constructor(
private val repository: TasksRepository,
repository: TasksRepository,
) : ViewModel() {
val state: StateFlow<ListsUiState> =
@@ -65,7 +56,7 @@ class ListsViewModel @Inject constructor(
repository.tasks(TaskFilter.Smart(SmartList.COMPLETED)),
) { lists, openTasks, completedTasks ->
buildContent(lists, openTasks, completedTasks) as ListsUiState
}.recoveringFromProviderFailure { ListsUiState.Failure }
}.catch { emit(ListsUiState.Failure) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListsUiState.Loading)
private fun buildContent(
@@ -121,22 +112,4 @@ class ListsViewModel @Inject constructor(
allTasks = openTasks + completedTasks,
)
}
private val _writeFailure = MutableStateFlow<ListWriteFailure?>(null)
/** Set when a list write is refused; the screen shows it and clears it. */
val writeFailure: StateFlow<ListWriteFailure?> = _writeFailure.asStateFlow()
fun clearWriteFailure() { _writeFailure.value = null }
/**
* Create a device-only list. The lists flow picks it up on the store change;
* a refusal (External mode, a provider that says no) surfaces through
* [writeFailure] rather than vanishing, because the sheet has already closed.
*/
fun createList(name: String, color: Int) = viewModelScope.launch {
if (name.isBlank()) return@launch
runCatching { repository.createLocalList(name.trim(), color) }
.onFailure { _writeFailure.value = ListWriteFailure.SAVE }
}
}

View File

@@ -1,25 +1,13 @@
package de.jeanlucmakiola.agendula.ui.permission
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
data class PermissionUiState(
@@ -32,51 +20,23 @@ data class PermissionUiState(
* Gates app entry: is a tasks provider installed, and do we hold its permissions?
* The Composable owns the actual permission-launcher and store intents; this VM
* supplies the [status] and the exact permission strings to ask for.
*
* In the default Own mode this gate never appears at all — the store is our own
* Room database, so there is nothing to install and nothing to grant. It exists
* for External mode, which also makes it the only screen an External user can
* reach once their provider app stops answering: hence [useOwnStore].
*/
@HiltViewModel
class PermissionViewModel @Inject constructor(
private val repository: TasksRepository,
private val providerResolver: ProviderResolver,
private val prefs: SettingsPrefs,
) : ViewModel() {
private val refreshes = MutableStateFlow(0)
private val _state = MutableStateFlow(PermissionUiState())
val state: StateFlow<PermissionUiState> = _state.asStateFlow()
// 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())
init { refresh() }
/** Re-read provider + permission state (call after returning from a request). */
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 {
fun refresh() {
val provider = providerResolver.resolve()
return PermissionUiState(
_state.value = PermissionUiState(
status = repository.providerStatus(),
// Null in OWN mode, where there is no provider and nothing to grant.
permissionsToRequest = provider
?.let { listOf(it.readPermission, it.writePermission) }
.orEmpty(),

View File

@@ -49,7 +49,6 @@ import androidx.compose.material.icons.rounded.AccountTree
import androidx.compose.material.icons.rounded.Circle
import androidx.compose.material.icons.rounded.Flag
import androidx.compose.material.icons.rounded.Percent
import androidx.compose.material.icons.rounded.Storage
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -57,6 +56,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -75,11 +75,13 @@ import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.agendula.ui.export.ExportScreen
import de.jeanlucmakiola.floret.components.AboutCard
import de.jeanlucmakiola.floret.components.AboutLink
import de.jeanlucmakiola.floret.components.CollapsingScaffold
@@ -94,22 +96,10 @@ import de.jeanlucmakiola.floret.locale.AppLanguage
import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
import de.jeanlucmakiola.agendula.ui.common.OnResume
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel
/** The settings sub-screens reached from the hub's category rows. */
private enum class SettingsSection {
Appearance,
TaskForm,
Reminders,
Storage,
Export,
;
/** Where back goes: Export is opened from Storage, not from the hub. */
val parent: SettingsSection?
get() = if (this == Export) Storage else null
}
private enum class SettingsSection { Appearance, TaskForm, Reminders }
/**
* Token-based accent for a leading icon chip (container / on-container pair),
@@ -135,7 +125,7 @@ fun SettingsScreen(
// Inside a sub-screen, system back (button or gesture) returns to the hub
// rather than popping the whole Settings destination to the lists overview.
BackHandler(enabled = section != null) { section = section?.parent }
BackHandler(enabled = section != null) { section = null }
Box(
modifier = modifier
@@ -153,19 +143,6 @@ fun SettingsScreen(
SlideInSection(visible = section == SettingsSection.Reminders) {
RemindersScreen(state = state, viewModel = viewModel, onBack = { section = null })
}
// Storage stays composed under Export, so the deeper screen slides over it.
val storageOpen = section == SettingsSection.Storage ||
section?.parent == SettingsSection.Storage
SlideInSection(visible = storageOpen) {
StorageScreen(
viewModel = viewModel,
onOpenExport = { section = SettingsSection.Export },
onBack = { section = null },
)
}
SlideInSection(visible = section == SettingsSection.Export) {
ExportScreen(onBack = { section = SettingsSection.Storage })
}
}
}
@@ -235,13 +212,6 @@ private fun SettingsHub(
leading = { CategoryIcon(Icons.Default.Notifications, ChipAccent.Primary) },
onClick = { onOpenSection(SettingsSection.Reminders) },
)
GroupedRow(
title = stringResource(R.string.settings_section_storage),
summary = stringResource(R.string.settings_storage_subtitle),
position = Position.Middle,
leading = { CategoryIcon(Icons.Rounded.Storage, ChipAccent.Neutral) },
onClick = { onOpenSection(SettingsSection.Storage) },
)
LanguageRow(position = Position.Middle)
ReportProblemRow(position = Position.Bottom)
@@ -700,10 +670,15 @@ private fun rememberExactAlarmAllowed(context: Context): Boolean {
context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms(),
)
}
OnResume {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
allowed = context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms()
val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(lifecycle) {
val obs = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
allowed = context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms()
}
}
lifecycle.addObserver(obs)
onDispose { lifecycle.removeObserver(obs) }
}
return allowed
}

View File

@@ -3,27 +3,18 @@ package de.jeanlucmakiola.agendula.ui.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.agendula.data.tasks.ProviderEnvironment
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import de.jeanlucmakiola.agendula.data.tasks.TaskProvider
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -32,22 +23,6 @@ data class SettingsUiState(
val lists: List<TaskList> = emptyList(),
)
/**
* The storage half of Settings: which store is active, and what picking the
* other one would mean on this device.
*
* Kept apart from [SettingsUiState] because that one is collected for the whole
* Activity lifetime to drive the theme, and re-probing PackageManager on every
* theme emission would be work for nothing.
*/
data class StorageUiState(
val mode: StorageMode,
/** The external provider installed here, or null when there is none to pick. */
val external: TaskProvider? = null,
/** That provider's own app name, for a row that names what it is switching to. */
val externalLabel: String? = null,
)
/**
* Drives both the Settings screen and the app theme (MainActivity collects the
* same instance), so a theme change applies app-wide at once.
@@ -55,48 +30,14 @@ data class StorageUiState(
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val prefs: SettingsPrefs,
private val resolver: ProviderResolver,
private val environment: ProviderEnvironment,
@IoDispatcher io: CoroutineDispatcher,
repository: TasksRepository,
) : ViewModel() {
// MainActivity collects this for the theme, above the permission gate and for
// the whole Activity lifetime — so the list flow must survive the pre-grant
// SecurityException and recover once permission is given, not die for good.
val state: StateFlow<SettingsUiState> =
combine(
prefs.settings,
repository.taskLists().recoveringFromProviderFailure { emptyList() },
) { settings, lists ->
combine(prefs.settings, repository.taskLists().catch { emit(emptyList()) }) { settings, lists ->
SettingsUiState(settings, lists)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState())
// Bumped to re-probe the device; installing a provider or granting its
// permission happens outside the app, so nothing else would emit.
private val providerProbe = MutableStateFlow(0)
// Null until the first emission lands: the mode comes from DataStore and the
// rest from PackageManager, off the main thread, so any seeded default would
// name the wrong store for the first frames. flowOn, because every field here
// costs a PackageManager lookup or a permission check.
val storage: StateFlow<StorageUiState?> =
combine(prefs.storageMode, providerProbe) { stored, _ ->
val external = resolver.resolveExternal()
StorageUiState(
// No stored choice is the normal state; show what autoMode resolves
// to rather than a default that may not be the store in use.
mode = stored ?: resolver.autoMode(),
external = external,
externalLabel = external?.packageName?.let(environment::appLabel),
)
}.flowOn(io).stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
/** Re-read the device's provider state, after a permission request or a resume. */
fun refreshStorage() = providerProbe.update { it + 1 }
fun setStorageMode(mode: StorageMode) = viewModelScope.launch { prefs.setStorageMode(mode) }
fun setThemeMode(mode: ThemeMode) = viewModelScope.launch { prefs.setThemeMode(mode) }
fun setDynamicColor(enabled: Boolean) = viewModelScope.launch { prefs.setDynamicColor(enabled) }
fun setDefaultList(id: Long?) = viewModelScope.launch { prefs.setDefaultListId(id) }

View File

@@ -1,203 +0,0 @@
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()),
)
}
}

View File

@@ -44,7 +44,6 @@ import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.Checklist
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material.icons.rounded.Flag
import androidx.compose.material3.Checkbox
@@ -98,8 +97,6 @@ import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskSection
import de.jeanlucmakiola.agendula.domain.TaskSections
import de.jeanlucmakiola.agendula.ui.common.priorityAccent
import de.jeanlucmakiola.agendula.ui.lists.ListEditorSheet
import de.jeanlucmakiola.agendula.ui.lists.ListWriteFailure
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SnackChip
import de.jeanlucmakiola.floret.components.SnackChipHeight
@@ -129,22 +126,8 @@ fun TaskListScreen(
val state by viewModel.state.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
val content = state as? TaskListUiState.Content
val list = content?.list
val listName = list?.name
val listName = content?.listName
val listId = (filter as? TaskFilter.OfList)?.listId
// Editing is offered for a device-only list. A collection that belongs to an
// account is the server's to rename or remove, not ours.
var editingList by rememberSaveable { mutableStateOf(false) }
val listWriteFailure by viewModel.listWriteFailure.collectAsStateWithLifecycle()
val listDeleted by viewModel.listDeleted.collectAsStateWithLifecycle()
// The list this screen is about is gone; there is nothing left to show.
LaunchedEffect(listDeleted) { if (listDeleted) onBack() }
LaunchedEffect(listWriteFailure) {
if (listWriteFailure != null) {
delay(4_000)
viewModel.clearListWriteFailure()
}
}
// One add affordance, never two: a real list with the setting on gets a pinned
// bottom quick-add bar; everything else (incl. smart lists, which have no single
// target list) gets the floating "New task" button.
@@ -179,16 +162,6 @@ 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,
)
},
@@ -226,43 +199,18 @@ fun TaskListScreen(
.height(SnackChipHeight),
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(
visible = undoTarget != null,
message = stringResource(R.string.task_deleted),
actionLabel = stringResource(R.string.undo),
onAction = {
undoTarget?.let { viewModel.undoDelete(it.taskId) }
undoTarget = null
},
)
} else {
SnackChip(
visible = true,
message = stringResource(listWriteFailureMessage(failure)),
)
}
SnackChip(
visible = undoTarget != null,
message = stringResource(R.string.task_deleted),
actionLabel = stringResource(R.string.undo),
onAction = {
undoTarget?.let { viewModel.undoDelete(it.taskId) }
undoTarget = null
},
)
}
}
}
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)
@@ -825,20 +773,20 @@ private fun SubtaskExpandButton(expanded: Boolean, onToggle: () -> Unit) {
/** A visual row in a flattened section run: a top-level task, or one of its subtasks. */
private sealed interface ListRow {
val key: String
val key: Long
data class Parent(val task: Task, val expandable: Boolean, val expanded: Boolean) : ListRow {
override val key: String get() = task.occurrenceKey
override val key: Long get() = task.taskId
}
data class Sub(val task: Task) : ListRow {
override val key: String get() = task.occurrenceKey
override val key: Long get() = task.taskId
}
/** The inline "add a subtask" row that closes an expanded group. */
data class AddSub(val parent: Task) : ListRow {
// Prefixed so it never collides with the task row it belongs to.
override val key: String get() = "add-${parent.occurrenceKey}"
// Negative so it never collides with a real (positive) provider task id.
override val key: Long get() = -parent.taskId
}
}

View File

@@ -5,17 +5,14 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.ui.lists.ListWriteFailure
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
@@ -31,13 +28,13 @@ sealed interface TaskListUiState {
data object Failure : TaskListUiState
/**
* [list] is the real list when the filter is a [TaskFilter.OfList] — it
* titles the bar and backs the edit action — and `null` for smart lists,
* where the screen falls back to the smart label.
* [listName] is the real list's name when the filter is a
* [TaskFilter.OfList] (for the top-bar title), `null` for smart lists
* the screen falls back to the smart label in that case.
*/
data class Content(
val tasks: List<Task>,
val list: TaskList? = null,
val listName: String? = null,
/** Whether the inline "add a subtask" row shows on expanded groups (M5 setting). */
val showAddSubtaskRow: Boolean = true,
/** Whether a real list uses the bottom quick-add bar instead of the FAB. */
@@ -68,8 +65,8 @@ class TaskListViewModel @Inject constructor(
val tasks = repository.tasks(f)
val content: kotlinx.coroutines.flow.Flow<TaskListUiState> = when (f) {
is TaskFilter.OfList ->
combine(tasks, repository.taskLists()) { rows, lists ->
TaskListUiState.Content(rows, lists.firstOrNull { it.id == f.listId })
combine(tasks, repository.taskLists()) { list, lists ->
TaskListUiState.Content(list, lists.firstOrNull { it.id == f.listId }?.name)
}
is TaskFilter.Smart ->
tasks.map { TaskListUiState.Content(it) }
@@ -90,10 +87,7 @@ class TaskListViewModel @Inject constructor(
}
}
.onStart { emit(TaskListUiState.Loading) }
// 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 }
.catch { emit(TaskListUiState.Failure) }
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskListUiState.Loading)
@@ -118,9 +112,6 @@ class TaskListViewModel @Inject constructor(
combine(ids.map { id -> repository.subtasks(id).map { id to it } }) { it.toMap() }
}
}
// Without this an exception here escapes stateIn's coroutine, past
// viewModelScope's SupervisorJob, and crashes the process.
.recoveringFromProviderFailure { emptyMap() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
/** The screen reports which expanded parents need their children fetched. */
@@ -129,7 +120,7 @@ class TaskListViewModel @Inject constructor(
fun bind(taskFilter: TaskFilter) { filter.value = taskFilter }
fun toggleComplete(task: Task) = viewModelScope.launch {
runCatching { repository.setCompleted(task.taskId, task.occurrenceStart, !task.isCompleted) }
runCatching { repository.setCompleted(task.taskId, !task.isCompleted) }
}
/** Swipe-delete: hide the row now; the screen's snackbar commits or restores it. */
@@ -158,36 +149,6 @@ class TaskListViewModel @Inject constructor(
runCatching { repository.createTask(TaskForm(title = title, listId = listId)) }
}
private val _listWriteFailure = MutableStateFlow<ListWriteFailure?>(null)
/** Set when a list write is refused; the screen shows it and clears it. */
val listWriteFailure: StateFlow<ListWriteFailure?> = _listWriteFailure.asStateFlow()
private val _listDeleted = MutableStateFlow(false)
/** Flips once the list this screen shows is really gone, so it can leave. */
val listDeleted: StateFlow<Boolean> = _listDeleted.asStateFlow()
fun clearListWriteFailure() { _listWriteFailure.value = null }
/** Rename / recolour the list this screen is showing. */
fun updateList(listId: Long, name: String, color: Int) = viewModelScope.launch {
if (name.isBlank()) return@launch
runCatching { repository.updateList(listId, name.trim(), color) }
.onFailure { _listWriteFailure.value = ListWriteFailure.SAVE }
}
/**
* Delete the list **and its tasks**. The screen navigates away on
* [listDeleted], not on the call — leaving first would strand a refusal on a
* screen that no longer exists.
*/
fun deleteList(listId: Long) = viewModelScope.launch {
runCatching { repository.deleteList(listId) }
.onSuccess { _listDeleted.value = true }
.onFailure { _listWriteFailure.value = ListWriteFailure.DELETE }
}
/** Inline "add subtask" from an expanded list group — files it under [parent]. */
fun quickAddSubtask(parent: Task, title: String) = viewModelScope.launch {
if (title.isBlank() || parent.listId <= 0L) return@launch

View File

@@ -0,0 +1,190 @@
<?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>

View File

@@ -0,0 +1,196 @@
<?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>

View File

@@ -122,32 +122,7 @@
<string name="lists_header">Lists</string>
<string name="new_task">New task</string>
<string name="lists_failure">Could not read your tasks.</string>
<string name="lists_empty">No task lists yet.</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="lists_empty">No task lists yet. Add one in your tasks app or with the + button.</string>
<string name="smart_today">Today</string>
<string name="smart_overdue">Overdue</string>
<string name="smart_upcoming">Upcoming</string>
@@ -246,37 +221,6 @@
<string name="settings_bottom_add_bar">Bottom quick-add bar</string>
<string name="settings_bottom_add_bar_hint">Add tasks from a bar pinned to the bottom of a list, instead of the floating button</string>
<string name="onboarding_use_own_store">Use this device\'s storage instead</string>
<!-- Storage and export -->
<string name="settings_section_storage">Storage</string>
<string name="settings_storage_subtitle">Where tasks are kept, and export</string>
<string name="settings_task_store">Task store</string>
<string name="settings_task_store_hint">Each store keeps its own tasks. Switching does not move them across — export first if you want a copy.</string>
<string name="settings_store_own">On this device</string>
<string name="settings_store_own_hint">Agendula\'s own storage. Nothing else to install.</string>
<string name="settings_store_external">Another task app</string>
<string name="settings_store_external_hint">Share tasks with the app that syncs them</string>
<string name="settings_store_external_missing">No compatible task app is installed</string>
<string name="settings_store_permission_denied">Permission denied</string>
<string name="settings_store_permission_denied_hint">The other app\'s tasks stay unreachable until you allow access. Tap to open app settings.</string>
<string name="settings_export">Export tasks</string>
<string name="settings_export_hint">Save your lists as iCalendar files</string>
<string name="export_hint">One .ics file per list, readable by other task and calendar apps. The ticked lists go to a folder you pick, or into a single zip.</string>
<string name="export_no_lists">No lists to export</string>
<string name="export_to_folder">Save to a folder</string>
<string name="export_to_zip">Save as a zip file</string>
<string name="export_running">Exporting…</string>
<plurals name="export_done">
<item quantity="one">Exported %1$d list</item>
<item quantity="other">Exported %1$d lists</item>
</plurals>
<string name="export_failed">The export could not be written</string>
<string name="export_failed_folder">The chosen folder could not be opened</string>
<string name="export_failed_read_only">The chosen folder is not writable</string>
<string name="export_failed_create">A file could not be created in the chosen folder</string>
<string name="export_failed_access">Access to the chosen location was lost</string>
<!-- Reminder lead times (custom amounts) -->
<plurals name="reminder_minutes">
<item quantity="one">%1$d minute before</item>

View File

@@ -1,22 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<!--
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/" />
<!-- No file-based backups; settings live in DataStore which is backed up by default. -->
</full-backup-content>

View File

@@ -1,21 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<!--
See backup_rules.xml: the WAL sidecars travel with the database, and
naming any <include> makes everything else excluded by default — which is
what keeps the archived `tasks.db.imported` out without an <exclude> that
lint would reject.
-->
<cloud-backup>
<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/" />
<!-- Allow DataStore backup, exclude nothing extra. -->
</cloud-backup>
<device-transfer>
<include domain="database" path="agendula-tasks.db" />
<include domain="database" path="agendula-tasks.db-wal" />
<include domain="database" path="agendula-tasks.db-shm" />
<include domain="file" path="datastore/" />
</device-transfer>
</data-extraction-rules>

View File

@@ -8,4 +8,6 @@
-->
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<locale android:name="en" />
<locale android:name="de" />
<locale android:name="pt-BR" />
</locale-config>

View File

@@ -1,61 +0,0 @@
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")
}
}

View File

@@ -1,198 +0,0 @@
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()
}
}
}

View File

@@ -36,6 +36,7 @@ class TaskMapperTest {
val task = TaskMapper.task(reader)
assertThat(task.id).isEqualTo(42L)
assertThat(task.taskId).isEqualTo(7L)
assertThat(task.listId).isEqualTo(3L)
assertThat(task.title).isEqualTo("Buy milk")
@@ -49,65 +50,6 @@ class TaskMapperTest {
assertThat(task.isSubtask).isTrue()
}
@Test
fun `an occurrence is identified by its recurrence-id anchor`() {
fun occurrence(columns: Map<String, Any?>) =
TaskMapper.task(MapColumnReader(columns + (Tasks.RRULE to "FREQ=DAILY")))
// instance_original_time is the provider's own RECURRENCE-ID and wins.
val anchored = occurrence(
mapOf(
Instances.TASK_ID to 7L,
Instances.INSTANCE_ORIGINAL_TIME to 500L,
Instances.INSTANCE_START to 900L,
),
)
assertThat(anchored.occurrenceStart?.toEpochMilliseconds()).isEqualTo(500L)
assertThat(anchored.occurrenceKey).isEqualTo("7@500")
// Older provider schemas omit it; the occurrence's start reconstructs it.
val byStart = occurrence(mapOf(Instances.TASK_ID to 7L, Instances.INSTANCE_START to 900L))
assertThat(byStart.occurrenceStart?.toEpochMilliseconds()).isEqualTo(900L)
// A series carrying only DUE anchors on the due date instead.
val byDue = occurrence(mapOf(Instances.TASK_ID to 7L, Instances.INSTANCE_DUE to 1_200L))
assertThat(byDue.occurrenceStart?.toEpochMilliseconds()).isEqualTo(1_200L)
}
@Test
fun `a non-recurring task has no occurrence anchor and keys by task id`() {
val task = TaskMapper.task(
MapColumnReader(mapOf(Tasks.ID to 4L, Instances.INSTANCE_START to 500L)),
)
assertThat(task.occurrenceStart).isNull()
assertThat(task.occurrenceKey).isEqualTo("4")
}
@Test
fun `recurrence is detected from rrule when is_recurring is absent`() {
// tasks.org's bundled provider is DB 22 and has no `is_recurring` column;
// reading it alone would report the series as one-off and send its edits
// to the master row, re-anchoring the whole thing.
val task = TaskMapper.task(
MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.RRULE to "FREQ=WEEKLY;BYDAY=MO")),
)
assertThat(task.isRecurring).isTrue()
}
@Test
fun `recurrence is detected from rdate alone`() {
val task = TaskMapper.task(
MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.RDATE to "20260720T090000Z")),
)
assertThat(task.isRecurring).isTrue()
}
@Test
fun `a plain task is not recurring`() {
val task = TaskMapper.task(MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.TITLE to "One-off")))
assertThat(task.isRecurring).isFalse()
}
@Test
fun `falls back to instance id when task_id missing, and list color when no task color`() {
val task = TaskMapper.task(

View File

@@ -85,57 +85,6 @@ class TaskWriteMapperTest {
assertThat(values[Tasks.TZ]).isNull()
}
@Test
fun `all-day timestamps are pinned to UTC midnight`() {
// 2026-07-20T22:00Z — i.e. local midnight on the 21st in Berlin (UTC+2).
// The provider resolves all-day dates against UTC, so storing this as-is
// would land the task on the 20th for anyone reading it back.
val berlinMidnight = Instant.fromEpochMilliseconds(1_784_412_000_000L)
val values = TaskWriteMapper.taskValues(
TaskForm(title = "Holiday", listId = 1L, start = berlinMidnight, due = berlinMidnight, isAllDay = true),
tzId = "Europe/Berlin",
)
val dayMs = 24L * 60 * 60 * 1000
assertThat(values[Tasks.DUE] as Long % dayMs).isEqualTo(0L)
assertThat(values[Tasks.DTSTART] as Long % dayMs).isEqualTo(0L)
}
@Test
fun `timed timestamps are written untouched`() {
val at = Instant.fromEpochMilliseconds(1_784_412_345_678L)
val values = TaskWriteMapper.taskValues(
TaskForm(title = "Standup", listId = 1L, start = at, due = at),
tzId = "Europe/Berlin",
)
assertThat(values[Tasks.DTSTART]).isEqualTo(1_784_412_345_678L)
assertThat(values[Tasks.DUE]).isEqualTo(1_784_412_345_678L)
}
@Test
fun `duration is always cleared so it cannot collide with due`() {
// The provider validates the *merged* row and throws "Only one of DUE or
// DURATION must be supplied" if the stored row still carries a duration.
val values = TaskWriteMapper.taskValues(
TaskForm(title = "x", listId = 1L, due = Instant.fromEpochMilliseconds(5_000L)),
tzId = "UTC",
)
assertThat(values.containsKey(Tasks.DURATION)).isTrue()
assertThat(values[Tasks.DURATION]).isNull()
}
@Test
fun `instance values drop list and parent, which an override cannot express`() {
val form = TaskForm(title = "x", listId = 4L, parentId = 7L, due = Instant.fromEpochMilliseconds(1_000L))
val values = TaskWriteMapper.instanceValues(form, tzId = "UTC")
assertThat(values.containsKey(Tasks.LIST_ID)).isFalse()
assertThat(values.containsKey(Tasks.PARENT_ID)).isFalse()
// …but still carries the edit itself.
assertThat(values[Tasks.TITLE]).isEqualTo("x")
assertThat(values[Tasks.DUE]).isEqualTo(1_000L)
}
@Test
fun `completion sets status, percent and timestamp, un-completion clears them`() {
val done = TaskWriteMapper.completionValues(completed = true, nowMillis = 999L)
@@ -148,22 +97,6 @@ class TaskWriteMapperTest {
assertThat(undone[Tasks.COMPLETED]).isNull()
}
@Test
fun `alarm carries every column the provider's validator demands`() {
val values = TaskWriteMapper.alarmValues(taskId = 12L, minutesBeforeDue = 30)
assertThat(values[TasksContract.Properties.TASK_ID]).isEqualTo(12L)
assertThat(values[TasksContract.Properties.MIMETYPE])
.isEqualTo("vnd.android.cursor.item/alarm")
assertThat(values[TasksContract.Alarm.MINUTES_BEFORE]).isEqualTo(30)
// REFERENCE must be present and non-negative, ALARM_TYPE present and
// non-zero (0 is excluded from the provider's has_alarms count).
assertThat(values[TasksContract.Alarm.REFERENCE]).isEqualTo(TasksContract.Alarm.REFERENCE_DUE)
assertThat(values[TasksContract.Alarm.ALARM_TYPE]).isEqualTo(TasksContract.Alarm.TYPE_MESSAGE)
// property_id must be absent or the insert is rejected.
assertThat(values.containsKey(TasksContract.Properties.PROPERTY_ID)).isFalse()
}
@Test
fun `local list uses the LOCAL account`() {
val values = TaskWriteMapper.localListValues("Inbox", 0x123)

View File

@@ -1,53 +0,0 @@
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)
}
}

View File

@@ -1,152 +0,0 @@
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)
}
}

View File

@@ -1,60 +0,0 @@
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)
}
}

View File

@@ -17,7 +17,7 @@ class TaskSortingTest {
val sorted = listOf(completed, noDate, dueLater, dueSooner).sortedWith(TaskSorting.DEFAULT)
assertThat(sorted.map { it.taskId }).containsExactly(3L, 2L, 4L, 1L).inOrder()
assertThat(sorted.map { it.id }).containsExactly(3L, 2L, 4L, 1L).inOrder()
}
@Test
@@ -27,6 +27,6 @@ class TaskSortingTest {
val sorted = listOf(low, high).sortedWith(TaskSorting.DEFAULT)
assertThat(sorted.map { it.taskId }).containsExactly(2L, 1L).inOrder()
assertThat(sorted.map { it.id }).containsExactly(2L, 1L).inOrder()
}
}

View File

@@ -11,6 +11,7 @@ fun testTask(
priority: Priority = Priority.NONE,
due: Instant? = null,
): Task = Task(
id = id,
taskId = id,
listId = listId,
title = title,
@@ -31,7 +32,6 @@ fun testTask(
accountName = null,
parentId = null,
isRecurring = false,
occurrenceStart = null,
distanceFromCurrent = 0,
created = null,
lastModified = null,

View File

@@ -1,313 +0,0 @@
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()
}
}
}

View File

@@ -1,59 +0,0 @@
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)
}
}

View File

@@ -1,454 +0,0 @@
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()
}
}

View File

@@ -1,7 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.hilt) apply false

View File

@@ -8,36 +8,23 @@ This document describes how Agendula is built **as it stands today**. For the
## 1. The thesis in one sentence
Agendula is a Material 3 Expressive task app that reads, writes and reminds
against a task store the user chooses: **its own database** (the default) or an
external provider app already on the device (OpenTasks, tasks.org) synced by
DAVx5, SmoothSync, DecSync CC and the like. It is the task-list sibling to
[Calendula](https://codeberg.org/jlmakiola/calendula), which does the same thing
for `CalendarContract`.
**Agendula owns storage but not, yet, sync.** That is a deliberate change from
the original "owns no database" thesis, settled in
[`STORAGE-AND-SYNC.md`](STORAGE-AND-SYNC.md): depending on a provider app being
installed made someone else's roadmap a gate on the app working at all. The
store is a **Room database of our own**, designed against RFC 5545's `VTODO` and
against what a CalDAV sync adapter will need — the reasoning is in
[`STORAGE-DECISION.md`](STORAGE-DECISION.md), the architecture and the plan it
implements in [`OWN-STORE.md`](OWN-STORE.md). It replaces a vendored copy of the
dmfs task provider, which was deleted along with the `:provider` module it lived
in. External mode is untouched by that and still speaks the dmfs
`TaskContract`. A sync adapter of our own is the 1.x arc, designed in
[`SYNC.md`](SYNC.md).
Agendula is a Material 3 Expressive **front-end** over the OpenTasks
`TaskContract` provider — it reads, writes, and reminds on top of a tasks store
that some other app (DAVx5, SmoothSync, DecSync CC, tasks.org, …) syncs over
CalDAV. **Agendula owns no database and no sync stack.** It is the task-list
sibling to [Calendula](https://codeberg.org/jlmakiola/calendula),
which does the same thing for `CalendarContract`.
The whole design hangs off one rule:
> The entire app talks to a `TasksRepository`. Only the data layer knows there
> is a Room database, a `ContentResolver`, a `TaskContract` or an authority
> string behind it. **Table and column names and the authority string never leak
> above the data layer.**
> is a `ContentResolver`, a `TaskContract`, or an authority string behind it.
> **Provider column names and the authority string never leak above the data
> layer.**
That rule is what let the entire store be swapped without a rewrite: replacing
the provider with Room changed no UI, no ViewModel and exactly one domain field
(`Task.id``Task.occurrenceStart`, §4.8). See §7.
This is what lets "Posture A" (front-end over an installed provider) become
"Posture B" (bundle the Apache-2.0 provider, be self-contained) without touching
the UI, the ViewModels, or the domain. See §7.
---
@@ -51,36 +38,30 @@ the provider with Room changed no UI, no ViewModel and exactly one domain field
│ domain models + Flows only
┌───────────────▼──────────────────────────────┐
Domain │ Models, TaskForm, TaskFilter, TaskSorting, │
TaskSections, RecurrenceExpander
│ (pure Kotlin, no Android) │
DayWindow (pure Kotlin, no Android)
└───────────────┬──────────────────────────────┘
│ TasksRepository (interface)
┌───────────────▼──────────────────────────────┐
Data │ TasksRepositoryImpl │
│ └ TasksDataSource (interface) │
│ └ ModeRoutingTasksDataSource │
├ RoomTasksDataSource (OWN)
└ AndroidTasksDataSource (EXTERNAL)
│ └ AndroidTasksDataSource
└ ContentResolver / TaskContract /
ProviderResolver / ContentObserver
│ reminders/ prefs/ di/ demo/ │
└────────┬────────────────────────┬────────────┘
│ Room DAOs │ content://
─────────────▼──────────┐ ┌──────────▼─────────────┐
Sto- │ Own mode (default): │ │ External mode:
rage │ agendula-tasks.db — │ OpenTasks / tasks.org │
│ four tables in our │ │ ←sync← DAVx5 / … │
│ own data directory, │ │ dangerous perms, │
│ nothing to permit │ │ asked at point of use │
└────────────────────────┘ └────────────────────────┘
└───────────────┬──────────────────────────────┘
│ content:// + dangerous perms
┌───────────────▼──────────────────────────────┐
External │ OpenTasks provider ←sync← DAVx5 / DecSync…
└──────────────────────────────────────────────┘
```
The seam that matters is the pair of interfaces in the data layer:
- **`TasksRepository`** — the only type the UI sees. Flow-based reads, suspend
writes. (`data/tasks/TasksRepository.kt`)
- **`TasksDataSource`** — the JVM-testable, domain-shaped interface that does the
actual store work. Two implementations: `RoomTasksDataSource` for our own
store and `AndroidTasksDataSource` for an external provider, picked per call by
`ModeRoutingTasksDataSource` (§4.1).
- **`TasksDataSource`** — the JVM-testable interface that does the actual
provider work; `AndroidTasksDataSource` is the only Android-coupled
implementation.
Both are bound in Hilt in `data/di/DataModule.kt`.
@@ -88,252 +69,84 @@ Both are bound in Hilt in `data/di/DataModule.kt`.
## 3. Module & package layout
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`.
Single `:app` module (Posture A). Package root `de.jeanlucmakiola.agendula`.
| Package (`:app`) | Contents |
| Package | Contents |
|---|---|
| `domain/` | `Models` (TaskList, Task, TaskDetail, enums + pure iCal↔domain value mappers), `TaskConstants` (status/priority/local-account constants), `TaskForm` (validated create/edit), `TaskFilter` + `TaskFiltering` (smart lists), `TaskSorting`, `TaskSections` (due-date sectioning), `AllDayTime` (the two date conventions). No Android imports; local-midnight maths comes from floret-kit's `DayWindow`. |
| `domain/recurrence/` | `RecurrenceExpander` — a stored rule set → its occurrences, over `lib-recur`. Pure Kotlin. |
| `domain/export/` | `ExportModels` + `ICalendarWriter` — VTODO serialization. Pure Kotlin, so the format is JVM-testable. |
| `data/tasks/` | `StorageMode` + `StorageModeHolder` + `ProviderResolver` + `ProviderEnvironment` (which store, §4.1), `ModeRoutingTasksDataSource`, `StartupGate`, `TasksDataSource`, `TasksRepository` + `Impl`, `Failures`; and the External-mode half — `TasksContract` (vendored subset), `TaskProjections`, `ColumnReader`, `TaskMapper` (cursor→domain), `TaskWriteMapper` (form→`ContentValues`), `AndroidTasksDataSource`. |
| `data/tasks/room/` | Agendula's own store: `Entities` (the four tables), a DAO per table, `TasksDatabase`, `Converters`, `RoomTasksDataSource`, `RoomTaskMapper` (row→domain), `TaskFormWriter` (form→entity), `DatabaseCheckpoint`. |
| `data/tasks/legacy/` | `OneShotImport` — a v0.3.x install's tasks out of the dmfs provider's file and into Room, once (§4.4). |
| `data/export/` | `TaskExporter` (lists → `.ics` documents), `ExportWriter` (SAF plumbing; a floret-kit candidate). |
| `domain/` | `Models` (TaskList, Task, TaskDetail, enums + pure iCal↔domain value mappers), `TaskForm` (validated create/edit), `TaskFilter` + `TaskFiltering` (smart lists), `TaskSorting`, `DayWindow` (local-midnight maths). No Android imports. |
| `data/tasks/` | `TasksContract` (vendored subset), `ProviderResolver` (the A/B seam), `TaskProjections`, `ColumnReader`, `TaskMapper` (cursor→domain), `TaskWriteMapper` (form→`ContentValues`), `TasksDataSource` + `AndroidTasksDataSource`, `TasksRepository` + `Impl`, `Failures`. |
| `data/reminders/` | `ReminderScheduler` (the self-scheduled engine), `DueReminderReceiver`, `BootReceiver`, `ProviderChangeReceiver`, `ScheduledReminderStore`, `TaskNotifier`. |
| `data/prefs/` | `SettingsPrefs` (DataStore). |
| `data/di/` | `DataModule` (binds + provides), `Qualifiers` (`@IoDispatcher`, `@ApplicationScope`). |
| `data/di/` | `DataModule` (binds + provides), `Qualifiers` (`@IoDispatcher`). |
| `data/demo/` | `DemoSeeder` (debug-only sample data). |
| `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`. |
| `ui/` | `theme/`, `common/` (GroupedList, ListChip), `lists/`, `tasklist/`, `detail/`, `edit/`, `settings/`, `permission/` (each a ViewModel + UiState; `lists` also has its screen), `RootScreen`. |
| root | `AgendulaApp` (Hilt app), `MainActivity`. |
---
## 4. The data layer (the heart)
### 4.1 Which store — `StorageMode` and `ProviderResolver`
### 4.1 Provider targeting — `ProviderResolver`
`StorageMode` has two values, `OWN` and `EXTERNAL`, and
`ModeRoutingTasksDataSource` picks the implementation **per call** — the mode is
a setting the user can change while the process lives, so binding it once would
mean rebuilding the object graph to honour a change.
`ProviderResolver.resolve()` walks a preference-ordered candidate list and
returns the first provider actually installed (via
`PackageManager.resolveContentProvider`), or `null` if none is. Each candidate
is a `TaskProvider(authority, readPermission, writePermission, packageName)`.
| Mode | Store | Authority | Permissions |
|---|---|---|---|
| **Own** (default) | our Room database, `agendula-tasks.db` | — none, it is not a provider | **none** |
| External | OpenTasks | `org.dmfs.tasks` | `org.dmfs.permission.READ_TASKS` / `WRITE_TASKS` |
| External | tasks.org | `org.tasks.opentasks` | `org.tasks.permission.READ_TASKS` / `WRITE_TASKS` |
| Provider | Authority | Permissions |
|---|---|---|
| OpenTasks | `org.dmfs.tasks` | `org.dmfs.permission.READ_TASKS` / `WRITE_TASKS` |
| tasks.org | `org.tasks.opentasks` | `org.tasks.permission.READ_TASKS` / `WRITE_TASKS` |
`ProviderResolver` is now only about the second and third rows: it discovers the
*external* providers a device has. `resolve()` returns `null` in `OWN` mode —
there is no authority to resolve — and callers that need to tell that apart from
"External, and nothing installed" ask `mode()`. `TaskProvider` no longer carries
an `isOwn` flag; there is no own provider to flag.
Both are backed by the same dmfs `TaskProvider`, so the **same `TaskContract`
columns apply** regardless of which is present. `null` from `resolve()` drives
the "install a tasks provider" onboarding gate. `hasPermission()` checks both
runtime perms for the active provider.
`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
### 4.2 `TasksContract`
A vendored subset of the Apache-2.0 OpenTasks `TaskContract` — column names,
table paths, status/priority constants, the local-account type. Agendula does
**not** take a runtime dependency on OpenTasks; the authority is injected from
`ProviderResolver`, never hardcoded in the contract. 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.
`ProviderResolver`, never hardcoded in the contract.
### 4.6 Reads and reactivity
### 4.3 Reads — Instances + `ContentObserver`
In `OWN` mode `RoomTasksDataSource` reads through the DAOs and expands
recurrences (§4.3). In `EXTERNAL` mode `AndroidTasksDataSource` queries the
denormalized **instances** view (so each occurrence is a row with the joined list
colour, account, etc.) and maps each cursor row through `ColumnReader` →
`TaskMapper` → domain `Task`.
`AndroidTasksDataSource` queries the denormalized **instances** view (so each
occurrence is a row with the joined list colour, account, etc.), maps each
cursor row through `ColumnReader``TaskMapper` → domain `Task`, and exposes
the result as a `Flow`. A `ContentObserver` on the active authority's
Tasks/TaskLists URIs bridges into the Flow via `callbackFlow`, so **any change
re-emits** — Agendula's own writes *and* external sync (DAVx5 pulling new tasks)
update the UI live, and multiple sync sources coexist in one list.
`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
### 4.4 Writes — repository API
```kotlin
interface TasksRepository {
fun taskLists(): Flow<List<TaskList>>
fun tasks(filter: TaskFilter): Flow<List<Task>>
fun subtasks(parentId: Long): Flow<List<Task>>
fun taskDetail(taskId: Long): Flow<TaskDetail?>
suspend fun createTask(form: TaskForm): Long
suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant? = null)
suspend fun updateTask(taskId: Long, form: TaskForm)
suspend fun setCompleted(taskId: Long, completed: Boolean) // the core gesture
suspend fun deleteTask(taskId: Long)
suspend fun reminderFor(taskId: Long): Int?
suspend fun createLocalList(name: String, color: Int): Long
fun providerStatus(): ProviderStatus // READY | NEEDS_PERMISSION | NO_PROVIDER
}
```
`updateTask` on an occurrence of a recurring task routes to
`TasksDataSource.updateInstance(taskId, occurrenceStart, form)` rather than
moving the series anchor. `expectedLastModified` re-checks the stored timestamp
first and throws `TaskConflictException` when something changed underneath the
form.
`TaskWriteMapper` turns a validated `TaskForm` into `ContentValues`. Completion
sets `STATUS = COMPLETED` (+ percent/completed timestamp); DAVx5 syncs that back
out as a normal VTODO status change. Writes to local/unsynced lists use the
sync-adapter URI form where the provider requires it.
Completion sets `STATUS = COMPLETED` (+ percent/completed timestamp); in
External mode DAVx5 syncs that back out as a normal VTODO status change.
### 4.5 Domain model notes
### 4.8 Domain model notes
- `Task.taskId` is the task row and the stable target for edits, completion and
navigation. There is no `Task.id` any more — it was the materialised instance
row id, and materialised instances are gone.
- `Task.occurrenceStart` is the occurrence's `RECURRENCE-ID` anchor, `null` for a
non-recurring task; `Task.occurrenceKey` (`"$taskId@$millis"`) is what lazy
lists key by. Two occurrences of one series can appear in the same list, so
`taskId` alone would collide there — as a Compose key that is a visible bug.
- `Task.id` is the **instance** row id; `Task.taskId` is the underlying
`tasks._id` and the stable target for edits/completion.
- Subtasks are carried via `parentId` (`RELATED-TO` / `RELATION_TYPE_PARENT`);
`TaskDetail` bundles a task with its direct children.
- `effectiveColor` = the task's own colour, else the list colour.
@@ -347,7 +160,7 @@ External mode DAVx5 syncs that back out as a normal VTODO status change.
`TaskFilter` is either `OfList(listId)` or `Smart(SmartList)`. The smart lists —
`ALL, TODAY, UPCOMING, OVERDUE, NO_DATE, COMPLETED` — are computed from due
dates, not membership. `TaskFiltering.matches()` is a **pure predicate** taking
`todayStart`/`todayEnd` (local-midnight bounds from floret-kit's `DayWindow`), so it
`todayStart`/`todayEnd` (local-midnight bounds from `DayWindow`), so it
unit-tests with a fixed clock. `TaskSorting` orders within a list (due /
priority / etc.). None of this touches Android, which is why it's all in
`domain/`.
@@ -368,24 +181,10 @@ providers broadcast nothing**, so Agendula schedules its own (`data/reminders/`)
`set` when `canScheduleExactAlarms()` is false), keyed by `taskId`.
- **`DueReminderReceiver`** fires → posts via `TaskNotifier` (channel,
`POST_NOTIFICATIONS` gate, dedupe-by-tag).
- **Re-sync triggers:** app start (after `StartupGate`), **`BootReceiver`**
(re-arm after reboot), and **`ProviderChangeReceiver`**. The store lets each
run diff like Calendula diffs reminder rows.
`sync()` gates on **`ProviderResolver.canReadStore()`**, not on a provider
resolving. `OWN` is always readable; only `EXTERNAL` can fail, and only for the
two reasons it ever could (nothing installed, or no grant). Gating on
`resolve() != null` — as it did briefly — clears every alarm the moment `OWN` is
active, because `OWN` resolves to no provider by design. `ProviderResolverTest`
covers both directions.
`ProviderChangeReceiver`'s manifest filter now lists **only the two external
authorities**: Agendula publishes no provider and broadcasts no
`ACTION_PROVIDER_CHANGED`, so there is nothing of ours to listen for. In `OWN`
mode Room's `InvalidationTracker` covers foreground changes and nothing outside
the app can change our data. When `SYNC.md` phase 3 lands, the sync worker calls
`ReminderScheduler.sync()` itself — that is the replacement for the broadcast,
and it belongs in the sync work.
- **Re-sync triggers:** app start, **`BootReceiver`** (re-arm after reboot), and
**`ProviderChangeReceiver`** (`PROVIDER_CHANGED` on both authorities → external
sync changed the data). The store lets each run diff like Calendula diffs
reminder rows.
This is the single largest piece of genuinely-new code in Agendula.
@@ -393,46 +192,17 @@ This is the single largest piece of genuinely-new code in Agendula.
## 7. The A / B seam (why the layering is shaped this way)
Both terms were **redefined** by [`STORAGE-AND-SYNC.md`](STORAGE-AND-SYNC.md).
They no longer mean what earlier drafts of this document said.
- **Posture A (today):** front-end over whatever provider is installed. Ships
fast; requires a provider app present (the "needs DAVx5/OpenTasks" onboarding
moment).
- **Posture B (later):** add a `:provider` module bundling the Apache-2.0
`opentasks-provider`. `ProviderResolver` then finds **our own** `org.dmfs.tasks`
first; external CalDAV engines sync directly into it. **The UI, ViewModels,
domain, and `TasksRepository` do not change** — only the resolver's default and
some manifest perms.
- **Posture A** — front-end over an *external* provider (OpenTasks, tasks.org).
Still fully supported; it stopped being the only option and became a user
choice, `StorageMode.EXTERNAL`.
- **Posture B (shipped, then rebuilt)** — a store of our own, `StorageMode.OWN`.
It first shipped as the `:provider` module, the Apache-2.0 dmfs provider
vendored under our own authority; it is now a Room database and the module is
deleted. What made the vendored provider worth keeping was the sync bookkeeping
it appeared to hand us for free, and the phase-1 sync audit measured that
bookkeeping and found most of it broken, absent or unusable — the argument and
the costing are in [`STORAGE-DECISION.md`](STORAGE-DECISION.md).
> **Dead end, do not revisit:** publishing a task provider under dmfs's *own*
> authority so DAVx5 would sync into it unwittingly. Two apps cannot declare the
> same authority (`INSTALL_FAILED_CONFLICTING_PROVIDER`) or the same
> `<permission>` name (`INSTALL_FAILED_DUPLICATE_PERMISSION`), so anyone with
> OpenTasks installed simply could not have installed Agendula. Account
> visibility is also keyed by *package*, not authority, which would have left such
> a provider seeing zero accounts and pruning synced lists as orphaned. Full
> reasoning in `STORAGE-AND-SYNC.md`.
The seam earned its keep twice over. `ProviderResolver` is still the only thing
that knows an authority exists and `AndroidTasksDataSource` the only thing that
touches a resolver, so vendoring an entire content provider changed nothing above
the data layer — and **replacing** it with a database of our own changed no UI,
no ViewModel and exactly one domain field (`Task.id` → `occurrenceStart`), which
was forced by dropping materialised instances rather than by the store swap
itself.
⚠️ **The authority is gone, and that is a breaking change.**
`de.jeanlucmakiola.agendula.tasks` and both custom permissions no longer exist,
so anyone who had pointed DAVx5 or another app at that authority loses it.
External mode is the answer for them; it needs saying in the release notes.
Owning the store owns **storage, not sync**. Our own sync adapter is a separate,
later piece of work — designed in [`SYNC.md`](SYNC.md), and it lands *underneath*
this same seam: it writes through the DAOs, so the layers above stay untouched a
third time.
Bundling the provider bundles **storage, not sync** — Agendula stays a pure
front-end over open backends either way.
---
@@ -446,38 +216,21 @@ fallback in `ui/theme/`). Each screen area (`lists`, `tasklist`, `detail`,
`RootScreen` is the entry composable: it gates on `ProviderStatus`
(`NO_PROVIDER` / `NEEDS_PERMISSION` → onboarding `Gate`; `READY`
`AgendulaNavHost`). In `OWN` mode the status is always `READY`, so that gate is
only ever seen in External mode — and it offers a way back to our own store,
because it is the only screen an External user can reach once their provider app
stops answering. Routes are the `Dest` table in `ui/navigation/` (lists → task
list → detail / edit, plus settings). Follow the `material-3` skill for component
choices (M3 `ListItem` rows, expressive checkbox/FAB/swipe motion).
Settings is a hub of sliding sub-screens rather than routes; **Storage** is the
one with teeth. It holds the §4.1 store picker — which asks for an external
provider's runtime permission *before* writing the mode, so a denial leaves the
readable store in place instead of stranding the user on the gate — and the
export screen (`ui/export/`, one `.ics` per ticked list, written through SAF to a
folder or a single zip). Because the mode is now switchable while the process
lives, `AgendulaApp` re-arms reminders on `ProviderResolver.onModeChanged`: an
alarm is scheduled off whichever store was active at the time, so the whole set
has to be rebuilt against the new one.
`ListsScreen`). The remaining screens are being built one at a time — their
ViewModels exist and are tested against the real data layer; navigation
callbacks are currently stubs (see [`ROADMAP.md`](ROADMAP.md)). Follow the
`material-3` skill for component choices (M3 `ListItem` rows, expressive
checkbox/FAB/swipe motion).
---
## 9. Dependency injection
Hilt, `SingletonComponent`. `DataModule` has a `@Binds` module (`TasksRepository`
→ `TasksRepositoryImpl`, `ProviderEnvironment` → `AndroidProviderEnvironment`)
and a `@Provides` module (the `agendula_prefs` DataStore, `TasksDatabase`, the
`@IoDispatcher`, the `@ApplicationScope`). `TasksDataSource` is `@Provides`
rather than `@Binds`, because it is a `ModeRoutingTasksDataSource` over
`Provider<RoomTasksDataSource>` and `Provider<AndroidTasksDataSource>` — both
singletons, so it picks between two long-lived objects rather than building
either. `AgendulaApp` is the `@HiltAndroidApp` entry point and pulls
`StartupGate`, `DatabaseCheckpoint` and `ReminderScheduler` through an
`@EntryPoint`; `MainActivity` is `@AndroidEntryPoint`. ViewModels get the
repository injected.
Hilt, `SingletonComponent`. `DataModule` has a `@Binds` module
(`TasksDataSource``AndroidTasksDataSource`, `TasksRepository`
`TasksRepositoryImpl`) and a `@Provides` module (the `agendula_prefs` DataStore,
the `@IoDispatcher`). `AgendulaApp` is the `@HiltAndroidApp` entry point;
`MainActivity` is `@AndroidEntryPoint`. ViewModels get the repository injected.
---
@@ -488,9 +241,8 @@ repository injected.
| Build | AGP 9.2.1, Kotlin 2.3.21, KSP, Hilt 2.59.2, Java 17 |
| SDK | compileSdk 37, minSdk 29 (Android 10), targetSdk 36 |
| UI | Compose BOM 2026.05.01, Material3 `1.5.0-alpha21` (Expressive APIs), Glance 1.1.1 (widget, later) |
| Store | Room 2.8.4 (KSP, `room.schemaLocation = app/schemas`, WAL), `org.dmfs:lib-recur` 0.12.2 pinned (0.16.0 removed `RecurrenceSet`; `rfc5545-datetime` arrives with it as part of its API surface) |
| Other | DataStore, DocumentFile (SAF export), kotlinx-datetime, kotlinx-coroutines, the `floret-kit` included build |
| Tests | JVM: JUnit5 (Jupiter) + Truth + Turbine + coroutines-test, with the data source, `ProviderEnvironment`, `TaskFormWriter` and `RecurrenceExpander` as the JVM-testable seams. Instrumented (`app/src/androidTest`, AndroidJUnitRunner + Truth): the Room schema, `RoomTasksDataSource` and `OneShotImport` — the last against `assets/tasks-v23.db`, a fixture written by `scripts/make_import_fixture.py` in the provider's DATABASE_VERSION 23 schema, since the provider it came from no longer exists to test against. |
| Other | DataStore, kotlinx-datetime, kotlinx-coroutines |
| Tests | JUnit5 (Jupiter) + Truth + Turbine + coroutines-test; the data source is the JVM-testable seam |
| Versioning | committed `versionName` is the source of truth; a bump reaching `main` triggers the release and the pipeline mints the `vX.Y.Z` tag. `versionCode = MAJOR*10000 + MINOR*100 + PATCH`. See [`RELEASING.md`](RELEASING.md). |
| CI | Split by forge: `.forgejo/workflows/ci.yaml` on Codeberg (canonical, no secrets), `.gitea/workflows/release.yaml` on Gitea (all secrets). See [`RELEASING.md`](RELEASING.md). |
| Distribution | F-Droid (`fdroid-metadata/`) + Codeberg release APKs |
@@ -500,27 +252,12 @@ repository injected.
## 11. Manifest surface
- **Permissions:** both `org.dmfs.*` and `org.tasks.*` read/write tasks perms
(static manifest, so they are always declared; requested at runtime only in
External mode); `POST_NOTIFICATIONS`, `RECEIVE_BOOT_COMPLETED`, exact-alarm
(`USE_EXACT_ALARM` on 33+, `SCHEDULE_EXACT_ALARM` ≤32). `OWN` mode needs
nothing here at all: it is a database in our own data directory.
- **No `<provider>` and no custom permissions.** Agendula publishes no content
provider; `de.jeanlucmakiola.agendula.tasks`, the
`de.jeanlucmakiola.agendula.permission.*` pair and their permission group all
went with the `:provider` module.
- **Deliberately absent:** `GET_ACCOUNTS`, and `INTERNET`, which stays undeclared
until sync actually ships. Export needs no storage permission at all; SAF hands
us a `Uri` the user picked.
- **`<queries>`** for package visibility: both external provider authorities +
a LAUNCHER intent (so `resolveContentProvider` works and onboarding can open
the provider / a store listing).
- **Receivers:** `DueReminderReceiver` (not exported), `BootReceiver`, and
`ProviderChangeReceiver` — the latter filtering on the two *external*
authorities only (an intent-filter host must be a literal). No `EVENT_REMINDER`
receiver — that's a Calendula thing that doesn't apply here.
- **Backup:** `agendula-tasks.db` plus its `-wal` and `-shm` sidecars are
included in both `backup_rules.xml` and `data_extraction_rules.xml`;
`tasks.db.imported` is excluded, since it is a copy of data already imported.
Room runs in WAL mode and Auto Backup copies files without checkpointing, so
`DatabaseCheckpoint` runs `PRAGMA wal_checkpoint(TRUNCATE)` on `ON_STOP` to
keep the `.db` alone current for a restore that drops the sidecars.
declared statically (the active set is requested at runtime);
`POST_NOTIFICATIONS`, `RECEIVE_BOOT_COMPLETED`, exact-alarm
(`USE_EXACT_ALARM` on 33+, `SCHEDULE_EXACT_ALARM` ≤32).
- **`<queries>`** for package visibility: both provider authorities + a LAUNCHER
intent (so `resolveContentProvider` works and onboarding can open the
provider / a store listing).
- **Receivers:** `DueReminderReceiver` (not exported), `BootReceiver`,
`ProviderChangeReceiver` (both authorities). No `EVENT_REMINDER` receiver —
that's a Calendula thing that doesn't apply here.

View File

@@ -1,621 +0,0 @@
# 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.52 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.52 |
| 3 | Semantics parity | 1 |
| 4 | Import and cutover | 1 |
| 5 | Delete `:provider` | 0.5 |
| 6 | Harden | 1 |
| | | **6.57 wk** |
Against which `SYNC.md`'s own estimate drops by 2.54 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.56 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 34. |
| **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 (~11.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.

View File

@@ -1,24 +1,5 @@
# Agendula — implementation plan
> ⚠️ **Historical document.** This is the original design plan, kept for the
> reasoning behind decisions that are still in force — the layering, the data
> model, the reminder engine, what transfers from Calendula. It is **not** a
> description of the app as it stands.
>
> Two things here have since been overturned, both by
> [`STORAGE-AND-SYNC.md`](STORAGE-AND-SYNC.md), which supersedes this document
> wherever they disagree:
>
> 1. **"No own storage."** Agendula now ships its own bundled task provider, and
> depending on an external provider app is a user choice rather than a
> requirement.
> 2. **"Posture B = bundle OpenTasks under `org.dmfs.tasks`."** That is a dead
> end, not a later step — two apps cannot declare the same authority or
> permission name. Posture B shipped under *our own* authority instead.
>
> For the current picture see [`ARCHITECTURE.md`](ARCHITECTURE.md); for status,
> [`ROADMAP.md`](ROADMAP.md).
> A modern Material 3 Expressive **task** app for Android. Reads, writes, and
> reminds — on top of an existing tasks provider (synced by DAVx5 / SmoothSync /
> DecSync over CalDAV), with no own sync stack.

Some files were not shown because too many files have changed in this diff Show More