From 522d5ca35e06bf97c5ca2bc62125cb81dc896f54 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 15 Oct 2026 14:25:23 +0200 Subject: [PATCH] Let other apps write to the own store through the provider (#63) --- CHANGELOG.md | 3 + README.md | 2 +- .../provider/AgendulaTaskProviderTest.kt | 71 +- .../data/tasks/provider/ProviderWriterTest.kt | 692 ++++++++++++++++++ .../tasks/room/RoomTasksDataSourceTest.kt | 17 + app/src/main/AndroidManifest.xml | 18 +- .../data/tasks/ModeRoutingTasksDataSource.kt | 1 + .../agendula/data/tasks/TasksDataSource.kt | 8 + .../data/tasks/TasksRepositoryImpl.kt | 10 +- .../tasks/provider/AgendulaTaskProvider.kt | 45 +- .../data/tasks/provider/ProviderSnapshot.kt | 49 +- .../tasks/provider/ProviderSnapshotSource.kt | 53 +- .../data/tasks/provider/ProviderTaskWriter.kt | 257 +++++++ .../data/tasks/provider/ProviderWriter.kt | 414 +++++++++++ .../data/tasks/room/RoomTasksDataSource.kt | 44 ++ .../agendula/data/tasks/room/TaskAlarmDao.kt | 7 + app/src/main/res/values/strings.xml | 3 + .../data/tasks/provider/ProviderIdsTest.kt | 67 ++ .../tasks/provider/ProviderSnapshotTest.kt | 45 ++ .../tasks/provider/ProviderTaskWriterTest.kt | 401 ++++++++++ docs/CONTENT_PROVIDER.md | 85 ++- docs/PRIVACY.md | 24 +- 22 files changed, 2250 insertions(+), 66 deletions(-) create mode 100644 app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderWriterTest.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderTaskWriter.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderWriter.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderIdsTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderTaskWriterTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b9e27d..1113192 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ All notable changes to this project are documented here. The format follows OpenTasks-compatible provider, once you grant them permission. Apps that already support OpenTasks or tasks.org need only a small change to support Agendula too (#43). +- With a second permission, other apps can also add, edit, complete and delete + those tasks, including their reminders and tags. They can tick off a single + occurrence of a recurring task. Task lists stay read-only for them (#63). ## [1.1.0] - 2026-10-10 diff --git a/README.md b/README.md index d7ee1f8..adc1121 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ Agendula's own store is an ordinary app database, so it **coexists with OpenTasks rather than replacing it**. If you already sync through a provider, Agendula can work on top of it exactly as before. -Other apps can read the tasks in Agendula's own store through an +Other apps can read and write the tasks in Agendula's own store through an OpenTasks-compatible provider, once you grant them permission. See [docs/CONTENT_PROVIDER.md](docs/CONTENT_PROVIDER.md). diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/provider/AgendulaTaskProviderTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/provider/AgendulaTaskProviderTest.kt index 132849b..fd0f678 100644 --- a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/provider/AgendulaTaskProviderTest.kt +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/provider/AgendulaTaskProviderTest.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.agendula.data.tasks.provider +import android.content.ContentProviderOperation import android.content.ContentValues import android.content.Context import android.content.pm.PermissionInfo @@ -21,27 +22,31 @@ class AgendulaTaskProviderTest { private val context = ApplicationProvider.getApplicationContext() private val authority = "${context.packageName}.tasks" - private val permission = "${context.packageName}.permission.READ_TASKS" + private val readPermission = "${context.packageName}.permission.READ_TASKS" + private val writePermission = "${context.packageName}.permission.WRITE_TASKS" private val resolver = context.contentResolver private fun uri(path: String) = "content://$authority/$path".toUri() @Test - fun isExportedUnderItsPermission() { + fun isExportedUnderItsPermissions() { val info = requireNotNull(context.packageManager.resolveContentProvider(authority, 0)) assertThat(info.name).isEqualTo(AgendulaTaskProvider::class.java.name) assertThat(info.exported).isTrue() - assertThat(info.readPermission).isEqualTo(permission) - assertThat(info.writePermission).isEqualTo(permission) + assertThat(info.readPermission).isEqualTo(readPermission) + assertThat(info.writePermission).isEqualTo(writePermission) } @Test - fun thePermissionIsDangerous() { - val info = context.packageManager.getPermissionInfo(permission, 0) + fun bothPermissionsAreDangerous() { + listOf(readPermission, writePermission).forEach { permission -> + val info = context.packageManager.getPermissionInfo(permission, 0) - assertThat(info.protection).isEqualTo(PermissionInfo.PROTECTION_DANGEROUS) - assertThat(info.loadLabel(context.packageManager).toString()).isNotEmpty() + assertThat(info.protection).isEqualTo(PermissionInfo.PROTECTION_DANGEROUS) + assertThat(info.loadLabel(context.packageManager).toString()).isNotEmpty() + assertThat(info.loadDescription(context.packageManager)?.toString()).isNotEmpty() + } } @Test @@ -53,6 +58,16 @@ class AgendulaTaskProviderTest { assertThat(output).contains("Permission Denial") } + @Test + fun anAppWithoutTheWritePermissionCannotWrite() { + assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) + + val output = shell("content insert --uri content://$authority/tasks --bind title:s:x --bind list_id:i:1") + + assertThat(output).contains("Permission Denial") + assertThat(output).contains(writePermission) + } + @Test fun reportsOpenTasksMimeTypes() { ProviderTable.entries.forEach { table -> @@ -92,12 +107,42 @@ class AgendulaTaskProviderTest { } @Test - fun writesAreRefused() { - val values = ContentValues().apply { put("title", "x") } + fun listsAreReadOnly() { + val values = ContentValues().apply { put("list_name", "x") } - assertThrows(UnsupportedOperationException::class.java) { resolver.insert(uri("tasks"), values) } - assertThrows(UnsupportedOperationException::class.java) { resolver.update(uri("tasks/1"), values, null, null) } - assertThrows(UnsupportedOperationException::class.java) { resolver.delete(uri("tasks/1"), null, null) } + assertThrows(UnsupportedOperationException::class.java) { resolver.insert(uri("tasklists"), values) } + assertThrows(UnsupportedOperationException::class.java) { resolver.update(uri("tasklists/1"), values, null, null) } + assertThrows(UnsupportedOperationException::class.java) { resolver.delete(uri("tasklists/1"), null, null) } + } + + @Test + fun aRowUriTakesNoInsert() { + assertThrows(IllegalArgumentException::class.java) { + resolver.insert(uri("tasks/1"), ContentValues().apply { put("title", "x") }) + } + } + + @Test + fun aBadWriteReachesTheClientAsIllegalArgument() { + // No list has this id, so nothing in the app's real store is touched. + val values = ContentValues().apply { + put("list_id", Long.MAX_VALUE) + put("title", "x") + } + + assertThrows(IllegalArgumentException::class.java) { resolver.insert(uri("tasks"), values) } + } + + @Test + fun aFailingBatchWritesNothing() { + val missingList = Long.MAX_VALUE + val operations = arrayListOf( + ContentProviderOperation.newInsert(uri("tasks")).withValue("list_id", missingList).withValue("title", "a").build(), + ) + + assertThrows(IllegalArgumentException::class.java) { resolver.applyBatch(authority, operations) } + resolver.query(uri("tasks"), arrayOf("_id"), "list_id = ?", arrayOf(missingList.toString()), null)!! + .use { assertThat(it.count).isEqualTo(0) } } /** stdout and stderr of [command]; the denial lands on stderr. */ diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderWriterTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderWriterTest.kt new file mode 100644 index 0000000..e9c01a4 --- /dev/null +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderWriterTest.kt @@ -0,0 +1,692 @@ +package de.jeanlucmakiola.agendula.data.tasks.provider + +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.ProviderEnvironment +import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver +import de.jeanlucmakiola.agendula.data.tasks.StorageMode +import de.jeanlucmakiola.agendula.data.tasks.TasksContract +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Alarm +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Category +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks +import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity +import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference +import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource +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.TaskStatus +import org.junit.After +import org.junit.Assert.assertThrows +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.Duration.Companion.hours +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.awaitCancellation +import kotlin.time.Instant + +/** Writes from another app, applied to the own store through the published tables. */ +@RunWith(AndroidJUnit4::class) +class ProviderWriterTest { + + private val context = ApplicationProvider.getApplicationContext() + private lateinit var db: TasksDatabase + private lateinit var resolver: ProviderResolver + private lateinit var source: ProviderSnapshotSource + private lateinit var writer: ProviderWriter + private var afterWrites = 0 + private var home = 0L + + /** Truncated to the store's granularity, and on the hour so occurrences land on round times. */ + private val today = Instant.fromEpochMilliseconds(Clock.System.now().toEpochMilliseconds() / 3_600_000 * 3_600_000) + + @Before + fun setUp() { + db = Room.inMemoryDatabaseBuilder(context, TasksDatabase::class.java).allowMainThreadQueries().build() + resolver = ProviderResolver(NoProvider) + source = ProviderSnapshotSource(context, db, resolver, awaitStartup = {}) + writer = ProviderWriter(db, RoomTasksDataSource(db), source, resolver, afterWrite = { afterWrites++ }) + home = db.taskLists().insert(TaskListEntity(name = "Home", color = 0)) + } + + @After + fun tearDown() = db.close() + + private fun task(id: Long) = db.tasks().entity(id) + + private fun insert(vararg values: Pair): Long = + writer.insert(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to home, *values)) + + private fun byId(table: ProviderTable, id: Long) = "${table.idColumn} = $id" + + private fun synced(readOnly: Boolean = false): Long { + val account = db.accounts().insert(AccountEntity(displayName = "me", username = "me")) + return db.taskLists().insert( + TaskListEntity(name = "Work", color = 0, accountId = account, href = "https://s/w/", isReadOnly = readOnly), + ) + } + + private fun dailySeries(title: String = "Daily"): Long = db.tasks().insert( + TaskEntity(listId = home, uid = title, title = title, dtstart = today - 1.days, due = today - 1.days + 1.hours, + rrule = "FREQ=DAILY;COUNT=5", timezone = "UTC"), + ) + + /** Each instance of [taskId]'s series by its original time, with its task row and status. */ + private fun instances(): List> = + source.rows( + ProviderTable.INSTANCES, + listOf(Instances.INSTANCE_ORIGINAL_TIME, Instances.TASK_ID, Tasks.STATUS), + null, + null, + ).map { (original, taskId, status) -> Triple(original as Long, taskId as Long, (status as Long).toInt()) } + + private fun instanceId(original: Instant): Long = + source.rows( + ProviderTable.INSTANCES, + listOf(Tasks.ID), + "${Instances.INSTANCE_ORIGINAL_TIME} = ?", + arrayOf(original.toEpochMilliseconds().toString()), + ).single().single() as Long + + // --- inserts ---------------------------------------------------------------- + + @Test + fun anInsertedTaskIsPublishedAndOwedASync() { + val id = insert(Tasks.TITLE to "Milk", Tasks.PRIORITY to 1) + + val stored = task(id)!! + assertThat(stored.title).isEqualTo("Milk") + assertThat(stored.priority).isEqualTo(1) + assertThat(stored.isDirty).isTrue() + assertThat(source.rows(ProviderTable.TASKS, listOf(Tasks.TITLE), byId(ProviderTable.TASKS, id), null)) + .containsExactly(listOf("Milk")) + assertThat(afterWrites).isEqualTo(1) + } + + @Test + fun anInsertNeedsAListThatExistsAndIsWritable() { + assertThrows(IllegalArgumentException::class.java) { writer.insert(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x")) } + assertThrows(IllegalArgumentException::class.java) { insert(Tasks.LIST_ID to 999L) } + val readOnly = synced(readOnly = true) + assertThrows(UnsupportedOperationException::class.java) { insert(Tasks.LIST_ID to readOnly) } + assertThat(db.tasks().live()).isEmpty() + } + + @Test + fun anOccurrenceCannotBeInsertedAsATask() { + val series = dailySeries() + + assertThrows(IllegalArgumentException::class.java) { + insert(Tasks.ORIGINAL_INSTANCE_ID to series, Tasks.ORIGINAL_INSTANCE_TIME to today.toEpochMilliseconds()) + } + } + + @Test + fun aDuplicateUidIsRejected() { + insert(Tasks.UID to "same") + + assertThrows(IllegalArgumentException::class.java) { insert(Tasks.UID to "same") } + } + + @Test + fun listsAndInstancesTakeNoInserts() { + assertThrows(UnsupportedOperationException::class.java) { + writer.insert(ProviderTable.LISTS, mapOf(TasksContract.Lists.NAME to "New")) + } + assertThrows(UnsupportedOperationException::class.java) { writer.insert(ProviderTable.INSTANCES, emptyMap()) } + assertThrows(UnsupportedOperationException::class.java) { + writer.update(ProviderTable.LISTS, mapOf(TasksContract.Lists.NAME to "x"), null, null) + } + assertThrows(UnsupportedOperationException::class.java) { writer.delete(ProviderTable.LISTS, null, null) } + } + + @Test + fun anUnknownColumnIsRejected() { + assertThrows(IllegalArgumentException::class.java) { insert("etag" to "x") } + } + + @Test + fun nothingIsWrittenInExternalMode() { + val id = insert(Tasks.TITLE to "Mine") + resolver.storageMode = StorageMode.EXTERNAL + + assertThrows(IllegalStateException::class.java) { insert() } + assertThrows(IllegalStateException::class.java) { + writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), null, null) + } + assertThat(task(id)!!.title).isEqualTo("Mine") + } + + // --- updates ---------------------------------------------------------------- + + @Test + fun anUpdateChangesTheRowsItsSelectionMatches() { + val a = insert(Tasks.TITLE to "a") + val b = insert(Tasks.TITLE to "b") + + val count = writer.update(ProviderTable.TASKS, mapOf(Tasks.STATUS to 2), "${Tasks.TITLE} = ?", arrayOf("b")) + + assertThat(count).isEqualTo(1) + assertThat(task(a)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION) + assertThat(task(b)!!.status).isEqualTo(TaskStatus.COMPLETED) + assertThat(task(b)!!.completedAt).isNotNull() + } + + @Test + fun anUpdateMatchingNothingChangesNothing() { + insert(Tasks.TITLE to "a") + + assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), "${Tasks.TITLE} = 'zzz'", null)).isEqualTo(0) + } + + @Test + fun aTaskInAReadOnlyListCannotChange() { + val readOnly = synced(readOnly = true) + val id = db.tasks().insert(TaskEntity(listId = readOnly, uid = "r", title = "theirs")) + + assertThrows(UnsupportedOperationException::class.java) { + writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "mine"), byId(ProviderTable.TASKS, id), null) + } + assertThrows(UnsupportedOperationException::class.java) { + writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, id), null) + } + assertThat(task(id)!!.title).isEqualTo("theirs") + } + + @Test + fun aTaskMovesBetweenLists() { + val work = synced() + val id = insert(Tasks.TITLE to "t") + + writer.update(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to work), byId(ProviderTable.TASKS, id), null) + + assertThat(db.tasks().live().single { it.title == "t" }.listId).isEqualTo(work) + } + + @Test + fun aParentMustBeInTheSameListAndNotADescendant() { + val work = synced() + val parent = insert(Tasks.TITLE to "parent") + val child = insert(Tasks.TITLE to "child", Tasks.PARENT_ID to parent) + val elsewhere = insert(Tasks.LIST_ID to work, Tasks.TITLE to "elsewhere") + + assertThat(task(child)!!.parentId).isEqualTo(parent) + assertThrows(IllegalArgumentException::class.java) { + writer.update(ProviderTable.TASKS, mapOf(Tasks.PARENT_ID to elsewhere), byId(ProviderTable.TASKS, child), null) + } + assertThrows(IllegalArgumentException::class.java) { + writer.update(ProviderTable.TASKS, mapOf(Tasks.PARENT_ID to child), byId(ProviderTable.TASKS, parent), null) + } + assertThrows(IllegalArgumentException::class.java) { insert(Tasks.PARENT_ID to 999L) } + } + + @Test + fun movingASeriesDropsTheOverridesItNoLongerGenerates() { + val series = dailySeries() + writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null) + assertThat(db.tasks().overrides(series)).hasSize(1) + + writer.update( + ProviderTable.TASKS, + mapOf(Tasks.DTSTART to (today + 2.hours).toEpochMilliseconds(), Tasks.DUE to (today + 3.hours).toEpochMilliseconds()), + byId(ProviderTable.TASKS, series), + null, + ) + + assertThat(db.tasks().overrides(series)).isEmpty() + } + + @Test + fun aClientCannotTouchSyncState() { + val work = synced() + val id = db.tasks().insert( + TaskEntity(listId = work, uid = "s", title = "t", href = "/s.ics", etag = "e", sequence = 2, unknownProperties = "X-A:1"), + ) + + writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "new", "_sync_id" to "x", "sync1" to "y"), byId(ProviderTable.TASKS, id), null) + + val stored = task(id)!! + assertThat(stored.title).isEqualTo("new") + assertThat(stored.href).isEqualTo("/s.ics") + assertThat(stored.etag).isEqualTo("e") + assertThat(stored.sequence).isEqualTo(2) + assertThat(stored.unknownProperties).isEqualTo("X-A:1") + assertThat(stored.isDirty).isTrue() + } + + // --- instances -------------------------------------------------------------- + + @Test + fun completingAnOccurrenceTicksOffOnlyThatOne() { + val series = dailySeries() + + val count = writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null) + + assertThat(count).isEqualTo(1) + assertThat(task(series)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION) + val override = db.tasks().overrides(series).single() + assertThat(override.recurrenceId).isEqualTo(today) + assertThat(override.status).isEqualTo(TaskStatus.COMPLETED) + val statuses = instances().associate { it.first to it.third } + assertThat(statuses[today.toEpochMilliseconds()]).isEqualTo(TasksContract.STATUS_COMPLETED) + assertThat(statuses.filterKeys { it != today.toEpochMilliseconds() }.values.toSet()) + .containsExactly(TasksContract.STATUS_NEEDS_ACTION) + } + + @Test + fun anOccurrenceCanBeCancelledAndReopened() { + val series = dailySeries() + + writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 3), byId(ProviderTable.INSTANCES, instanceId(today)), null) + assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.CANCELLED) + + writer.update(ProviderTable.INSTANCES, mapOf(Tasks.PERCENT_COMPLETE to 0), byId(ProviderTable.INSTANCES, instanceId(today)), null) + assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.NEEDS_ACTION) + } + + @Test + fun contentWrittenToAnOccurrenceEditsTheSeries() { + val series = dailySeries() + + writer.update(ProviderTable.INSTANCES, mapOf(Tasks.TITLE to "Renamed"), byId(ProviderTable.INSTANCES, instanceId(today)), null) + + assertThat(task(series)!!.title).isEqualTo("Renamed") + assertThat(db.tasks().overrides(series)).isEmpty() + } + + @Test + fun anOccurrenceCannotBeMoved() { + dailySeries() + + assertThrows(IllegalArgumentException::class.java) { + writer.update(ProviderTable.INSTANCES, mapOf(Tasks.DUE to 0L), byId(ProviderTable.INSTANCES, instanceId(today)), null) + } + } + + @Test + fun anOccurrenceHasNoPartialProgress() { + dailySeries() + + assertThrows(IllegalArgumentException::class.java) { + writer.update(ProviderTable.INSTANCES, mapOf(Tasks.PERCENT_COMPLETE to 50), byId(ProviderTable.INSTANCES, instanceId(today)), null) + } + } + + @Test + fun aPlainTaskInstanceWritesTheTask() { + val id = insert(Tasks.TITLE to "t", Tasks.DUE to today.toEpochMilliseconds()) + val instance = source.rows(ProviderTable.INSTANCES, listOf(Tasks.ID), "${Instances.TASK_ID} = $id", null).single().single() as Long + + writer.update(ProviderTable.INSTANCES, mapOf(Tasks.PERCENT_COMPLETE to 50, Tasks.DUE to (today + 1.days).toEpochMilliseconds()), byId(ProviderTable.INSTANCES, instance), null) + + assertThat(task(id)!!.status).isEqualTo(TaskStatus.IN_PROCESS) + assertThat(task(id)!!.due).isEqualTo(today + 1.days) + } + + @Test + fun deletingAnOccurrenceExceptsIt() { + val series = dailySeries() + + assertThat(writer.delete(ProviderTable.INSTANCES, byId(ProviderTable.INSTANCES, instanceId(today)), null)).isEqualTo(1) + + assertThat(task(series)!!.exdate).isNotNull() + assertThat(instances().map { it.first }).doesNotContain(today.toEpochMilliseconds()) + assertThat(instances()).hasSize(4) + } + + @Test + fun deletingAModifiedOccurrenceExceptsItToo() { + val series = dailySeries() + writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null) + + writer.delete(ProviderTable.INSTANCES, byId(ProviderTable.INSTANCES, instanceId(today)), null) + + assertThat(db.tasks().overrides(series)).isEmpty() + assertThat(instances().map { it.first }).doesNotContain(today.toEpochMilliseconds()) + } + + // --- deletes ---------------------------------------------------------------- + + @Test + fun aParentGoesWithItsSubtasks() { + val parent = insert(Tasks.TITLE to "parent") + val child = insert(Tasks.TITLE to "child", Tasks.PARENT_ID to parent) + val grandchild = insert(Tasks.TITLE to "grandchild", Tasks.PARENT_ID to child) + val other = insert(Tasks.TITLE to "other") + + assertThat(writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, parent), null)).isEqualTo(1) + + assertThat(task(parent)).isNull() + assertThat(task(child)).isNull() + assertThat(task(grandchild)).isNull() + assertThat(task(other)).isNotNull() + } + + @Test + fun aSyncedTaskLeavesATombstone() { + val work = synced() + val id = insert(Tasks.LIST_ID to work, Tasks.TITLE to "t") + + writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, id), null) + + assertThat(task(id)!!.isDeleted).isTrue() + assertThat(source.rows(ProviderTable.TASKS, listOf(Tasks.ID), null, null)).isEmpty() + assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), byId(ProviderTable.TASKS, id), null)).isEqualTo(0) + } + + @Test + fun deletingAnOverrideThroughTasksExceptsTheOccurrence() { + val series = dailySeries() + writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null) + val override = db.tasks().overrides(series).single().id + + writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, override), null) + + assertThat(task(series)).isNotNull() + assertThat(task(series)!!.exdate).isNotNull() + assertThat(instances()).hasSize(4) + } + + // --- properties ------------------------------------------------------------- + + @Test + fun anAlarmCanBeAddedChangedAndRemoved() { + val id = insert(Tasks.TITLE to "t", Tasks.DUE to (today + 1.days).toEpochMilliseconds()) + db.tasks().update(task(id)!!.copy(isDirty = false)) + + val propertyId = writer.insert( + ProviderTable.PROPERTIES, + mapOf(Properties.TASK_ID to id, Properties.MIMETYPE to Alarm.MIMETYPE, Alarm.MINUTES_BEFORE to "15", Alarm.REFERENCE to "1"), + ) + val alarm = db.alarms().forTask(id).single() + assertThat(alarm.minutesBefore).isEqualTo(15) + assertThat(alarm.reference).isEqualTo(AlarmReference.DUE) + assertThat(task(id)!!.isDirty).isTrue() + assertThat(propertyId).isEqualTo(ProviderIds.alarm(alarm.id)) + + writer.update(ProviderTable.PROPERTIES, mapOf(Alarm.MINUTES_BEFORE to 60, Alarm.REFERENCE to 2), byId(ProviderTable.PROPERTIES, propertyId), null) + assertThat(db.alarms().forTask(id).single().minutesBefore).isEqualTo(60) + assertThat(db.alarms().forTask(id).single().reference).isEqualTo(AlarmReference.START) + + writer.delete(ProviderTable.PROPERTIES, "${Properties.MIMETYPE} = ?", arrayOf(Alarm.MIMETYPE)) + assertThat(db.alarms().forTask(id)).isEmpty() + } + + @Test + fun tagsCanBeAddedRenamedAndRemoved() { + val id = insert(Tasks.TITLE to "t") + + val propertyId = writer.insert( + ProviderTable.PROPERTIES, + mapOf(Properties.TASK_ID to id, Properties.MIMETYPE to Category.MIMETYPE, Category.NAME to " errand "), + ) + assertThat(task(id)!!.categories).containsExactly("errand") + + writer.update(ProviderTable.PROPERTIES, mapOf(Category.NAME to "shopping"), byId(ProviderTable.PROPERTIES, propertyId), null) + assertThat(task(id)!!.categories).containsExactly("shopping") + + writer.insert(ProviderTable.PROPERTIES, mapOf(Properties.TASK_ID to id, Properties.MIMETYPE to Category.MIMETYPE, Category.NAME to "shopping")) + assertThat(task(id)!!.categories).containsExactly("shopping") + + writer.delete(ProviderTable.PROPERTIES, "${Category.NAME} = ?", arrayOf("shopping")) + assertThat(task(id)!!.categories).isEmpty() + } + + @Test + fun aPropertyCannotChangeItsKindOrTask() { + val id = insert(Tasks.TITLE to "t") + val other = insert(Tasks.TITLE to "o") + db.alarms().insert(TaskAlarmEntity(taskId = id, minutesBefore = 5)) + val propertyId = source.rows(ProviderTable.PROPERTIES, listOf(Properties.PROPERTY_ID), null, null).single().single() as Long + + assertThrows(IllegalArgumentException::class.java) { + writer.update(ProviderTable.PROPERTIES, mapOf(Properties.MIMETYPE to Category.MIMETYPE), byId(ProviderTable.PROPERTIES, propertyId), null) + } + assertThrows(IllegalArgumentException::class.java) { + writer.update(ProviderTable.PROPERTIES, mapOf(Properties.TASK_ID to other), byId(ProviderTable.PROPERTIES, propertyId), null) + } + } + + @Test + fun onlyAlarmsAndTagsCanBeWritten() { + val id = insert(Tasks.TITLE to "t") + + assertThrows(IllegalArgumentException::class.java) { + writer.insert(ProviderTable.PROPERTIES, mapOf(Properties.TASK_ID to id, Properties.MIMETYPE to "vnd.android.cursor.item/comment")) + } + assertThrows(IllegalArgumentException::class.java) { + writer.insert(ProviderTable.PROPERTIES, mapOf(Properties.TASK_ID to id, Properties.MIMETYPE to Alarm.MIMETYPE)) + } + } + + // --- batches ---------------------------------------------------------------- + + @Test + fun aBatchIsAllOrNothing() { + assertThrows(IllegalArgumentException::class.java) { + writer.batch { + insert(Tasks.TITLE to "first") + insert(Tasks.TITLE to "second") + insert(Tasks.LIST_ID to 999L) + } + } + + assertThat(db.tasks().live()).isEmpty() + assertThat(source.rows(ProviderTable.TASKS, listOf(Tasks.ID), null, null)).isEmpty() + } + + @Test + fun aBatchSeesItsOwnWritesAndReArmsRemindersOnce() { + writer.batch { + insert(Tasks.TITLE to "first") + writer.update(ProviderTable.TASKS, mapOf(Tasks.STATUS to 2), "${Tasks.TITLE} = ?", arrayOf("first")) + } + + assertThat(db.tasks().live().single().status).isEqualTo(TaskStatus.COMPLETED) + assertThat(afterWrites).isEqualTo(1) + } + + // --- review regressions ----------------------------------------------------- + + @Test + fun aBatchDeletingTwoPropertiesDeletesThoseTwo() { + val a = insert(Tasks.TITLE to "a") + val b = insert(Tasks.TITLE to "b") + listOf(5, 10).forEach { db.alarms().insert(TaskAlarmEntity(taskId = a, minutesBefore = it)) } + db.alarms().insert(TaskAlarmEntity(taskId = b, minutesBefore = 15)) + val ids = source.rows(ProviderTable.PROPERTIES, listOf(Properties.PROPERTY_ID), "${Properties.TASK_ID} = $a", null) + .map { it.single() as Long } + + writer.batch { ids.forEach { writer.delete(ProviderTable.PROPERTIES, byId(ProviderTable.PROPERTIES, it), null) } } + + assertThat(db.alarms().forTask(a)).isEmpty() + assertThat(db.alarms().forTask(b).map { it.minutesBefore }).containsExactly(15) + } + + @Test + fun aBatchDeletingTwoOccurrencesDeletesThoseTwo() { + dailySeries() + val first = instanceId(today) + val second = instanceId(today + 1.days) + + writer.batch { + writer.delete(ProviderTable.INSTANCES, byId(ProviderTable.INSTANCES, first), null) + writer.delete(ProviderTable.INSTANCES, byId(ProviderTable.INSTANCES, second), null) + } + + assertThat(instances().map { it.first }).containsExactly( + (today - 1.days).toEpochMilliseconds(), + (today + 2.days).toEpochMilliseconds(), + (today + 3.days).toEpochMilliseconds(), + ) + } + + @Test + fun anInstanceIdOutlivesAnotherOccurrencesDeletion() { + dailySeries() + val later = instanceId(today + 2.days) + + writer.delete(ProviderTable.INSTANCES, byId(ProviderTable.INSTANCES, instanceId(today)), null) + + assertThat(instanceId(today + 2.days)).isEqualTo(later) + assertThat(writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, later), null)) + .isEqualTo(1) + assertThat(instances().single { it.third == TasksContract.STATUS_COMPLETED }.first) + .isEqualTo((today + 2.days).toEpochMilliseconds()) + } + + @Test + fun aRecurringSubtaskOutsideTheWindowGoesWithItsParent() { + val parent = insert(Tasks.TITLE to "parent") + val longAgo = today - 1000.days + val subtask = db.tasks().insert( + TaskEntity(listId = home, uid = "old", title = "old", dtstart = longAgo, rrule = "FREQ=DAILY;COUNT=2", + timezone = "UTC", parentId = parent), + ) + + writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, parent), null) + + assertThat(task(subtask)).isNull() + } + + @Test + fun anOverriddenRecurringSubtaskGoesWholeWithItsParent() { + val parent = insert(Tasks.TITLE to "parent") + val subtask = dailySeries("sub") + db.tasks().update(task(subtask)!!.copy(parentId = parent)) + writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null) + + writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, parent), null) + + assertThat(task(subtask)).isNull() + assertThat(db.tasks().live()).isEmpty() + } + + @Test + fun aModifiedOccurrenceCanBeWrittenBackAsRead() { + val series = dailySeries() + writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null) + val override = db.tasks().overrides(series).single().id + val row = readRow(ProviderTable.TASKS, override) + + writer.update(ProviderTable.TASKS, row + (Tasks.TITLE to "Renamed"), byId(ProviderTable.TASKS, override), null) + + assertThat(task(override)!!.title).isEqualTo("Renamed") + } + + @Test + fun anInstanceCanBeWrittenBackAsReadToCompleteIt() { + val series = dailySeries() + val instance = instanceId(today) + val row = readRow(ProviderTable.INSTANCES, instance) + + writer.update(ProviderTable.INSTANCES, row + (Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instance), null) + + assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.COMPLETED) + assertThat(task(series)!!.dtstart).isEqualTo(today - 1.days) + } + + @Test + fun movingASubtreeMovesItsSubtasks() { + val work = synced() + val parent = insert(Tasks.TITLE to "parent") + val child = insert(Tasks.TITLE to "child", Tasks.PARENT_ID to parent) + val grandchild = insert(Tasks.TITLE to "grandchild", Tasks.PARENT_ID to child) + + writer.update(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to work), byId(ProviderTable.TASKS, parent), null) + + assertThat(listOf(parent, child, grandchild).map { task(it)!!.listId }).containsExactly(work, work, work) + assertThat(task(child)!!.parentId).isEqualTo(parent) + assertThat(task(grandchild)!!.parentId).isEqualTo(child) + } + + @Test + fun aMoveOntoATakenUidIsRejected() { + val work = synced() + insert(Tasks.LIST_ID to work, Tasks.UID to "same") + val id = insert(Tasks.UID to "same") + + assertThrows(IllegalArgumentException::class.java) { + writer.update(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to work), byId(ProviderTable.TASKS, id), null) + } + assertThat(task(id)!!.listId).isEqualTo(home) + } + + @Test + fun contentOnAModifiedOccurrenceStillEditsTheSeries() { + val series = dailySeries() + writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null) + val override = db.tasks().overrides(series).single().id + + writer.update(ProviderTable.INSTANCES, mapOf(Tasks.TITLE to "Renamed"), byId(ProviderTable.INSTANCES, instanceId(today)), null) + + assertThat(task(series)!!.title).isEqualTo("Renamed") + assertThat(task(override)!!.title).isEqualTo("Renamed") + assertThat(task(override)!!.status).isEqualTo(TaskStatus.COMPLETED) + } + + @Test + fun aWriteThatChangesNothingOwesNoSync() { + val id = insert(Tasks.TITLE to "t") + db.tasks().update(task(id)!!.copy(isDirty = false)) + val modified = task(id)!!.lastModified + + writer.update(ProviderTable.TASKS, readRow(ProviderTable.TASKS, id), byId(ProviderTable.TASKS, id), null) + writer.update(ProviderTable.TASKS, emptyMap(), byId(ProviderTable.TASKS, id), null) + + assertThat(task(id)!!.isDirty).isFalse() + assertThat(task(id)!!.lastModified).isEqualTo(modified) + } + + @Test + fun reopeningAnOpenOccurrenceForksNothing() { + val series = dailySeries() + + writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 0), byId(ProviderTable.INSTANCES, instanceId(today)), null) + + assertThat(db.tasks().overrides(series)).isEmpty() + } + + @Test + fun aWriteBeforeStartupSettlesIsRefused() { + val stuck = ProviderSnapshotSource(context, db, resolver, awaitStartup = { awaitCancellation() }, startupWait = 50.milliseconds) + val early = ProviderWriter(db, RoomTasksDataSource(db), stuck, resolver, afterWrite = {}) + + assertThrows(IllegalStateException::class.java) { early.insert(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to home)) } + assertThat(db.tasks().live()).isEmpty() + } + + @Test + fun aSingleRowWriteNeedsNoSnapshot() { + val id = insert(Tasks.TITLE to "t") + val unknown = 987_654L + + assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), byId(ProviderTable.TASKS, unknown), null)).isEqualTo(0) + assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), byId(ProviderTable.TASKS, id), null)).isEqualTo(1) + assertThat(writer.update(ProviderTable.INSTANCES, mapOf(Tasks.TITLE to "x"), byId(ProviderTable.INSTANCES, unknown), null)).isEqualTo(0) + assertThat(writer.delete(ProviderTable.PROPERTIES, byId(ProviderTable.PROPERTIES, unknown), null)).isEqualTo(0) + } + + /** Every column of one published row, as a client that read it would write it back. */ + private fun readRow(table: ProviderTable, id: Long): Map { + val columns = table.columns.map { it.first } + val row = source.rows(table, columns, byId(table, id), null).single() + return columns.zip(row).toMap() - table.idColumn + } + +} + +private object NoProvider : ProviderEnvironment { + override fun packageDeclaring(authority: String): String? = null + override fun isGranted(permission: String): Boolean = false + override fun appLabel(packageName: String): String? = null +} diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSourceTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSourceTest.kt index e54c1ea..01b2f9a 100644 --- a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSourceTest.kt +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSourceTest.kt @@ -539,4 +539,21 @@ class RoomTasksDataSourceTest { assertThat(thrown).isNotNull() } + + @Test + fun subtaskIdsNameRowsNotOccurrences() { + val parent = source.insertTask(form("parent")) + val longAgo = now - 1000.days + val outOfWindow = db.tasks().insert( + TaskEntity(listId = listId, uid = "old", title = "old", dtstart = longAgo, rrule = "FREQ=DAILY;COUNT=2", parentId = parent), + ) + val series = db.tasks().insert( + TaskEntity(listId = listId, uid = "s", title = "s", dtstart = now, rrule = "FREQ=DAILY;COUNT=3", parentId = parent), + ) + source.setCompletedInstance(series, now, true) + val plain = source.insertTask(form("plain").copy(parentId = parent)) + + assertThat(source.subtaskIds(parent)).containsExactly(outOfWindow, series, plain) + } } + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1ccc783..bca0623 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,14 +15,19 @@ - + + @@ -241,13 +246,14 @@ - + read Agendula tasks Allows the app to read your task lists and tasks stored in Agendula, including their reminders and tags. + + add, change and delete Agendula tasks + Allows the app to add, change, complete and delete tasks stored in Agendula, including their reminders and tags. It cannot change or delete task lists. diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderIdsTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderIdsTest.kt new file mode 100644 index 0000000..4ed11d1 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderIdsTest.kt @@ -0,0 +1,67 @@ +package de.jeanlucmakiola.agendula.data.tasks.provider + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import kotlin.time.Instant + +class ProviderIdsTest { + + @Test + fun `an instance id gives back its series`() { + val id = ProviderIds.instance(1234, Instant.parse("2026-10-15T09:00:00Z")) + + assertThat(ProviderIds.instanceSeries(id)).isEqualTo(1234L) + assertThat(ProviderIds.instanceSeconds(id)).isEqualTo(Instant.parse("2026-10-15T09:00:00Z").epochSeconds) + assertThat(id).isGreaterThan(0L) + } + + @Test + fun `a plain task's instance has no time`() { + assertThat(ProviderIds.instanceSeconds(ProviderIds.instance(7, null))).isEqualTo(0L) + } + + @Test + fun `occurrences of one series and of different series never share an id`() { + val at = Instant.parse("2026-10-15T09:00:00Z") + + val ids = listOf( + ProviderIds.instance(1, at), + ProviderIds.instance(1, Instant.fromEpochSeconds(at.epochSeconds + 1)), + ProviderIds.instance(2, at), + ProviderIds.instance(1, null), + ) + + assertThat(ids).containsNoDuplicates() + } + + @Test + fun `alarm ids are even and tag ids odd`() { + val alarm = ProviderIds.alarm(42) + val tags = ProviderIds.categories(42, listOf("a", "b")) + + assertThat(ProviderIds.isAlarm(alarm)).isTrue() + assertThat(ProviderIds.alarmRow(alarm)).isEqualTo(42L) + tags.values.forEach { + assertThat(ProviderIds.isAlarm(it)).isFalse() + assertThat(ProviderIds.categoryTask(it)).isEqualTo(42L) + } + } + + @Test + fun `a tag's id depends on its task and spelling only`() { + val alone = ProviderIds.categories(5, listOf("home")) + val among = ProviderIds.categories(5, listOf("work", "home", "errand")) + + assertThat(among["home"]).isEqualTo(alone["home"]) + assertThat(ProviderIds.categories(6, listOf("home"))["home"]).isNotEqualTo(alone["home"]) + } + + @Test + fun `two spellings with one hash still get two ids`() { + // "Aa" and "BB" share String.hashCode(). + val ids = ProviderIds.categories(5, listOf("Aa", "BB")) + + assertThat(ids.values).containsNoDuplicates() + assertThat(ids.values.map(ProviderIds::categoryTask)).containsExactly(5L, 5L) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderSnapshotTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderSnapshotTest.kt index 67716f1..bc2eeb8 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderSnapshotTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderSnapshotTest.kt @@ -323,4 +323,49 @@ class ProviderSnapshotTest { assertThat(row[Alarm.MESSAGE]).isEqualTo("Go") assertThat(row[Alarm.ALARM_TYPE]).isEqualTo(Alarm.TYPE_MESSAGE.toString()) } + + @Test + fun `an instance keeps its id when other occurrences come and go`() { + val start = Instant.parse("2026-10-09T09:00:00Z") + val master = TaskEntity(id = 10, listId = 1, uid = "s", dtstart = start, rrule = "FREQ=DAILY;COUNT=4", timezone = "UTC") + val plain = TaskEntity(id = 3, listId = 1, uid = "p") + fun ids(vararg tasks: TaskEntity) = build(tasks.toList()).rows.getValue(ProviderTable.INSTANCES) + .associate { it[Instances.INSTANCE_ORIGINAL_TIME] to it[Tasks.ID] } + + val before = ids(plain, master) + val after = ids(master.copy(exdate = "20261010T090000Z")) + + val third = (start + 2.days).toEpochMilliseconds() + assertThat(after[third]).isEqualTo(before[third]) + assertThat(after).hasSize(3) + } + + @Test + fun `an overridden occurrence keeps the id it had before`() { + val start = Instant.parse("2026-10-09T09:00:00Z") + val master = TaskEntity(id = 10, listId = 1, uid = "s", dtstart = start, rrule = "FREQ=DAILY;COUNT=3", timezone = "UTC") + val override = master.copy(id = 11, rrule = null, masterId = 10, recurrenceId = start + 1.days, title = "x") + fun idOfSecond(vararg tasks: TaskEntity) = build(tasks.toList()).rows.getValue(ProviderTable.INSTANCES) + .single { it[Instances.INSTANCE_ORIGINAL_TIME] == (start + 1.days).toEpochMilliseconds() }[Tasks.ID] + + assertThat(idOfSecond(master, override)).isEqualTo(idOfSecond(master)) + } + + @Test + fun `a property keeps its id when other properties come and go`() { + val a = TaskEntity(id = 3, listId = 1, uid = "a", categories = listOf("one", "two")) + val b = TaskEntity(id = 4, listId = 1, uid = "b", categories = listOf("three")) + fun ids(tasks: List, alarms: List) = build(tasks, alarms) + .rows.getValue(ProviderTable.PROPERTIES) + .associate { (it[Category.NAME] ?: it[Alarm.MINUTES_BEFORE]) to it[Properties.PROPERTY_ID] } + val first = TaskAlarmEntity(id = 7, taskId = 3, minutesBefore = 5) + val second = TaskAlarmEntity(id = 8, taskId = 4, minutesBefore = 10) + + val before = ids(listOf(a, b), listOf(first, second)) + val after = ids(listOf(a.copy(categories = listOf("two")), b), listOf(second)) + + assertThat(after["two"]).isEqualTo(before["two"]) + assertThat(after["three"]).isEqualTo(before["three"]) + assertThat(after["10"]).isEqualTo(before["10"]) + } } diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderTaskWriterTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderTaskWriterTest.kt new file mode 100644 index 0000000..fa75dd5 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderTaskWriterTest.kt @@ -0,0 +1,401 @@ +package de.jeanlucmakiola.agendula.data.tasks.provider + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.agendula.data.tasks.TasksContract +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks +import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity +import de.jeanlucmakiola.agendula.domain.TaskStatus +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Instant + +class ProviderTaskWriterTest { + + private val now = Instant.parse("2026-10-15T10:00:00Z") + private val earlier = Instant.parse("2026-10-01T10:00:00Z") + private val task = TaskEntity(id = 5, listId = 1, uid = "uid-5", title = "Old", lastModified = earlier) + + private fun patch(current: TaskEntity = task, vararg values: Pair) = + ProviderTaskWriter.patch(current, mapOf(*values), now, "Europe/Berlin") + + private fun millis(at: Instant) = at.toEpochMilliseconds() + + // --- column filtering --------------------------------------------------------- + + @Test + fun `published read-only columns are dropped`() { + val values = mapOf(Tasks.TITLE to "t", Tasks.LIST_NAME to "x", Tasks.IS_CLOSED to 1, "_sync_id" to "s") + + assertThat(ProviderTaskWriter.writable(ProviderTable.TASKS, values, WRITABLE_TASK_COLUMNS)) + .containsExactly(Tasks.TITLE, "t") + } + + @Test + fun `a column the table does not have is rejected`() { + val error = assertThrows { + ProviderTaskWriter.writable(ProviderTable.TASKS, mapOf("href" to "x", "etag" to "y"), WRITABLE_TASK_COLUMNS) + } + assertThat(error).hasMessageThat().contains("etag, href") + } + + @Test + fun `instances accept every task column and their own`() { + val values = mapOf(Tasks.STATUS to 2, Instances.INSTANCE_START to 0L) + + assertThat(ProviderTaskWriter.writable(ProviderTable.INSTANCES, values, WRITABLE_TASK_COLUMNS)) + .containsExactly(Tasks.STATUS, 2) + } + + // --- new tasks ---------------------------------------------------------------- + + @Test + fun `a new task needs a list`() { + assertThrows { + ProviderTaskWriter.newTask(mapOf(Tasks.TITLE to "t"), "u", now, "UTC") + } + } + + @Test + fun `a new task takes the client's uid or the minted one`() { + val minted = ProviderTaskWriter.newTask(mapOf(Tasks.LIST_ID to 3L), "minted", now, "UTC") + val own = ProviderTaskWriter.newTask(mapOf(Tasks.LIST_ID to 3L, Tasks.UID to " theirs "), "minted", now, "UTC") + + assertThat(minted.uid).isEqualTo("minted") + assertThat(own.uid).isEqualTo("theirs") + assertThat(own.listId).isEqualTo(3L) + assertThat(own.createdAt).isEqualTo(now) + assertThat(own.lastModified).isEqualTo(now) + assertThat(own.isDirty).isTrue() + } + + @Test + fun `a new task may have no title`() { + assertThat(ProviderTaskWriter.newTask(mapOf(Tasks.LIST_ID to 3L), "u", now, "UTC").title).isNull() + } + + // --- content ------------------------------------------------------------------ + + @Test + fun `only the columns given change`() { + val current = task.copy(description = "keep", priority = 3, location = "home") + + val edited = patch(current, Tasks.TITLE to " New ") + + assertThat(edited.title).isEqualTo("New") + assertThat(edited.description).isEqualTo("keep") + assertThat(edited.priority).isEqualTo(3) + assertThat(edited.location).isEqualTo("home") + assertThat(edited.lastModified).isEqualTo(now) + assertThat(edited.isDirty).isTrue() + } + + @Test + fun `blank text clears and null clears`() { + val current = task.copy(description = "d", url = "u") + + val edited = patch(current, Tasks.DESCRIPTION to " ", Tasks.URL to null) + + assertThat(edited.description).isNull() + assertThat(edited.url).isNull() + } + + @Test + fun `numbers arrive in whatever shape the client sent`() { + val edited = patch(task, Tasks.PRIORITY to "2", Tasks.CLASSIFICATION to 1L, Tasks.SORTING to 7, Tasks.TASK_COLOR to -16711936) + + assertThat(edited.priority).isEqualTo(2) + assertThat(edited.classification).isEqualTo(1) + assertThat(edited.sortOrder).isEqualTo(7) + assertThat(edited.color).isEqualTo(-16711936) + } + + @Test + fun `a null priority is no priority`() { + assertThat(patch(task.copy(priority = 4), Tasks.PRIORITY to null).priority).isEqualTo(TasksContract.PRIORITY_NONE) + } + + @Test + fun `out of range values are rejected`() { + assertThrows { patch(task, Tasks.PRIORITY to 10) } + assertThrows { patch(task, Tasks.CLASSIFICATION to 3) } + assertThrows { patch(task, Tasks.PERCENT_COMPLETE to 101) } + assertThrows { patch(task, Tasks.STATUS to 4) } + assertThrows { patch(task, Tasks.PRIORITY to "high") } + } + + @Test + fun `the uid cannot change`() { + assertThat(patch(task, Tasks.UID to "uid-5").uid).isEqualTo("uid-5") + assertThrows { patch(task, Tasks.UID to "other") } + } + + @Test + fun `a task cannot be its own parent`() { + assertThrows { patch(task, Tasks.PARENT_ID to 5L) } + assertThat(patch(task, Tasks.PARENT_ID to 9L).parentId).isEqualTo(9L) + assertThat(patch(task.copy(parentId = 9), Tasks.PARENT_ID to null).parentId).isNull() + } + + @Test + fun `a list can be changed but not cleared`() { + assertThat(patch(task, Tasks.LIST_ID to 2L).listId).isEqualTo(2L) + assertThrows { patch(task, Tasks.LIST_ID to null) } + } + + @Test + fun `sync state is left alone`() { + val synced = task.copy(href = "/t.ics", etag = "e1", sequence = 4, unknownProperties = "X-FOO:bar") + + val edited = patch(synced, Tasks.TITLE to "t") + + assertThat(edited.href).isEqualTo("/t.ics") + assertThat(edited.etag).isEqualTo("e1") + assertThat(edited.sequence).isEqualTo(4) + assertThat(edited.unknownProperties).isEqualTo("X-FOO:bar") + } + + // --- times -------------------------------------------------------------------- + + @Test + fun `a timed task takes the device zone unless it has one`() { + val due = Instant.parse("2026-10-20T15:00:00Z") + + assertThat(patch(task, Tasks.DUE to millis(due)).timezone).isEqualTo("Europe/Berlin") + assertThat(patch(task.copy(timezone = "UTC"), Tasks.DUE to millis(due)).timezone).isEqualTo("UTC") + assertThat(patch(task, Tasks.DUE to millis(due), Tasks.TZ to "America/New_York").timezone) + .isEqualTo("America/New_York") + } + + @Test + fun `an unknown zone is rejected`() { + assertThrows { patch(task, Tasks.DUE to 0L, Tasks.TZ to "Mars/Olympus") } + } + + @Test + fun `an all-day task is floored to UTC midnight and has no zone`() { + val due = Instant.parse("2026-10-20T15:00:00Z") + + val edited = patch(task.copy(timezone = "UTC"), Tasks.DUE to millis(due), Tasks.IS_ALLDAY to 1) + + assertThat(edited.isAllDay).isTrue() + assertThat(edited.due).isEqualTo(Instant.parse("2026-10-20T00:00:00Z")) + assertThat(edited.timezone).isNull() + } + + @Test + fun `due before start is rejected`() { + assertThrows { + patch(task, Tasks.DTSTART to millis(now), Tasks.DUE to millis(now - 1.hours)) + } + } + + @Test + fun `setting a due date drops a duration`() { + val edited = patch(task.copy(dtstart = now, duration = "PT1H"), Tasks.DUE to millis(now + 2.hours)) + + assertThat(edited.duration).isNull() + assertThat(edited.due).isEqualTo(now + 2.hours) + } + + @Test + fun `due and duration together are rejected`() { + assertThrows { + patch(task, Tasks.DTSTART to millis(now), Tasks.DUE to millis(now + 1.hours), Tasks.DURATION to "PT1H") + } + } + + @Test + fun `a duration must be an iCalendar one`() { + assertThat(patch(task, Tasks.DTSTART to millis(now), Tasks.DURATION to "P1W").duration).isEqualTo("P1W") + assertThat(patch(task, Tasks.DTSTART to millis(now), Tasks.DURATION to "PT1H30M").duration).isEqualTo("PT1H30M") + assertThrows { patch(task, Tasks.DURATION to "an hour") } + } + + // --- recurrence --------------------------------------------------------------- + + @Test + fun `a rule is stored without its property name`() { + assertThat(patch(task, Tasks.DUE to millis(now), Tasks.RRULE to "RRULE:FREQ=WEEKLY").rrule).isEqualTo("FREQ=WEEKLY") + } + + @Test + fun `a malformed rule is rejected`() { + assertThrows { patch(task, Tasks.DUE to millis(now), Tasks.RRULE to "FREQ=SOMETIMES") } + } + + @Test + fun `a rule needs a date`() { + assertThrows { patch(task, Tasks.RRULE to "FREQ=DAILY") } + } + + @Test + fun `dropping the rule drops its exceptions`() { + val series = task.copy(due = now, rrule = "FREQ=DAILY", exdate = "20261016T100000Z", rdate = "20261030T100000Z") + + val edited = patch(series, Tasks.RRULE to null) + + assertThat(edited.rrule).isNull() + assertThat(edited.exdate).isNull() + assertThat(edited.rdate).isNull() + } + + @Test + fun `an override carries no rule and stays in its series`() { + val override = task.copy(masterId = 4, recurrenceId = now, parentId = 2) + + assertThrows { patch(override, Tasks.RRULE to "FREQ=DAILY") } + assertThat(patch(override, Tasks.RRULE to null, Tasks.RDATE to null, Tasks.EXDATE to "").rrule).isNull() + assertThrows { patch(override, Tasks.LIST_ID to 2L) } + assertThrows { patch(override, Tasks.PARENT_ID to 3L) } + assertThat(patch(override, Tasks.LIST_ID to 1L, Tasks.PARENT_ID to 2L, Tasks.TITLE to "x").title).isEqualTo("x") + } + + // --- completion --------------------------------------------------------------- + + @Test + fun `completing sets progress and time`() { + val edited = patch(task, Tasks.STATUS to TasksContract.STATUS_COMPLETED) + + assertThat(edited.status).isEqualTo(TaskStatus.COMPLETED) + assertThat(edited.percentComplete).isEqualTo(100) + assertThat(edited.completedAt).isEqualTo(now) + } + + @Test + fun `completing keeps the client's completion time`() { + val edited = patch(task, Tasks.STATUS to TasksContract.STATUS_COMPLETED, Tasks.COMPLETED to millis(earlier)) + + assertThat(edited.completedAt).isEqualTo(earlier) + } + + @Test + fun `reopening clears progress and time`() { + val done = task.copy(status = TaskStatus.COMPLETED, percentComplete = 100, completedAt = earlier) + + val edited = patch(done, Tasks.STATUS to TasksContract.STATUS_NEEDS_ACTION) + + assertThat(edited.status).isEqualTo(TaskStatus.NEEDS_ACTION) + assertThat(edited.percentComplete).isNull() + assertThat(edited.completedAt).isNull() + } + + @Test + fun `cancelling keeps progress and clears the completion time`() { + val edited = patch(task.copy(percentComplete = 40, completedAt = earlier), Tasks.STATUS to TasksContract.STATUS_CANCELLED) + + assertThat(edited.status).isEqualTo(TaskStatus.CANCELLED) + assertThat(edited.percentComplete).isEqualTo(40) + assertThat(edited.completedAt).isNull() + } + + @Test + fun `progress implies a status`() { + assertThat(patch(task, Tasks.PERCENT_COMPLETE to 50).status).isEqualTo(TaskStatus.IN_PROCESS) + assertThat(patch(task, Tasks.PERCENT_COMPLETE to 0).status).isEqualTo(TaskStatus.NEEDS_ACTION) + val done = patch(task, Tasks.PERCENT_COMPLETE to 100) + assertThat(done.status).isEqualTo(TaskStatus.COMPLETED) + assertThat(done.completedAt).isEqualTo(now) + } + + @Test + fun `progress below 100 reopens a completed task`() { + val done = task.copy(status = TaskStatus.COMPLETED, percentComplete = 100, completedAt = earlier) + + val edited = patch(done, Tasks.PERCENT_COMPLETE to 60) + + assertThat(edited.status).isEqualTo(TaskStatus.IN_PROCESS) + assertThat(edited.completedAt).isNull() + } + + @Test + fun `a completion time alone completes and clearing it reopens`() { + val done = patch(task, Tasks.COMPLETED to millis(earlier)) + assertThat(done.status).isEqualTo(TaskStatus.COMPLETED) + assertThat(done.percentComplete).isEqualTo(100) + assertThat(done.completedAt).isEqualTo(earlier) + + val reopened = patch(done, Tasks.COMPLETED to null) + assertThat(reopened.status).isEqualTo(TaskStatus.NEEDS_ACTION) + assertThat(reopened.completedAt).isNull() + } + + @Test + fun `a write without completion columns leaves completion alone`() { + val done = task.copy(status = TaskStatus.COMPLETED, percentComplete = 100, completedAt = earlier) + + val edited = patch(done, Tasks.TITLE to "t") + + assertThat(edited.status).isEqualTo(TaskStatus.COMPLETED) + assertThat(edited.completedAt).isEqualTo(earlier) + } + + // --- occurrences -------------------------------------------------------------- + + @Test + fun `an occurrence is completed, cancelled or reopened`() { + assertThat(ProviderTaskWriter.occurrenceChange(mapOf(Tasks.STATUS to 2))).isEqualTo(OccurrenceChange.COMPLETE) + assertThat(ProviderTaskWriter.occurrenceChange(mapOf(Tasks.STATUS to 3))).isEqualTo(OccurrenceChange.CANCEL) + assertThat(ProviderTaskWriter.occurrenceChange(mapOf(Tasks.STATUS to 0))).isEqualTo(OccurrenceChange.REOPEN) + assertThat(ProviderTaskWriter.occurrenceChange(mapOf(Tasks.PERCENT_COMPLETE to 100))).isEqualTo(OccurrenceChange.COMPLETE) + assertThat(ProviderTaskWriter.occurrenceChange(mapOf(Tasks.PERCENT_COMPLETE to 0))).isEqualTo(OccurrenceChange.REOPEN) + assertThat(ProviderTaskWriter.occurrenceChange(mapOf(Tasks.COMPLETED to 1L))).isEqualTo(OccurrenceChange.COMPLETE) + assertThat(ProviderTaskWriter.occurrenceChange(mapOf(Tasks.COMPLETED to null))).isEqualTo(OccurrenceChange.REOPEN) + } + + @Test + fun `status wins over progress on an occurrence`() { + assertThat(ProviderTaskWriter.occurrenceChange(mapOf(Tasks.STATUS to 2, Tasks.PERCENT_COMPLETE to 100))) + .isEqualTo(OccurrenceChange.COMPLETE) + } + + @Test + fun `an occurrence has no partial progress`() { + assertThrows { ProviderTaskWriter.occurrenceChange(mapOf(Tasks.STATUS to 1)) } + assertThrows { ProviderTaskWriter.occurrenceChange(mapOf(Tasks.PERCENT_COMPLETE to 50)) } + } + + @Test + fun `values without completion say nothing about the occurrence`() { + assertThat(ProviderTaskWriter.occurrenceChange(mapOf(Tasks.TITLE to "t"))).isNull() + } + + @Test + fun `flags accept the shapes clients send`() { + listOf(1, 1L, true, "1", "true").forEach { + assertThat(patch(task, Tasks.DUE to millis(now + 1.days), Tasks.IS_ALLDAY to it).isAllDay).isTrue() + } + listOf(0, false, "0", null).forEach { + assertThat(patch(task.copy(isAllDay = true), Tasks.IS_ALLDAY to it).isAllDay).isFalse() + } + assertThrows { patch(task, Tasks.IS_ALLDAY to "yes") } + } + + // --- no-op writes ------------------------------------------------------------- + + @Test + fun `a write that changes nothing returns the row as it was`() { + val current = task.copy(description = "d", priority = 3, isDirty = false) + + assertThat(patch(current)).isSameInstanceAs(current) + assertThat(patch(current, Tasks.TITLE to "Old", Tasks.PRIORITY to 3L, Tasks.DESCRIPTION to "d")) + .isSameInstanceAs(current) + assertThat(patch(current, Tasks.TITLE to "New").isDirty).isTrue() + } + + @Test + fun `unchanged time columns are not a move`() { + val due = Instant.parse("2026-10-20T15:00:00Z") + val series = task.copy(due = due, timezone = "UTC", rrule = "FREQ=DAILY") + val readBack = mapOf( + Tasks.DUE to millis(due), Tasks.DTSTART to null, Tasks.TZ to "UTC", Tasks.IS_ALLDAY to 0, + Tasks.RRULE to "FREQ=DAILY", Tasks.RDATE to null, Tasks.EXDATE to null, Tasks.DURATION to null, + ) + + assertThat(ProviderTaskWriter.changedTimeColumns(series, readBack, "Europe/Berlin")).isEmpty() + assertThat(ProviderTaskWriter.changedTimeColumns(series, readBack + (Tasks.DUE to millis(due + 1.hours)), "Europe/Berlin")) + .contains(Tasks.DUE) + } +} diff --git a/docs/CONTENT_PROVIDER.md b/docs/CONTENT_PROVIDER.md index cd78bed..4874369 100644 --- a/docs/CONTENT_PROVIDER.md +++ b/docs/CONTENT_PROVIDER.md @@ -1,21 +1,23 @@ -# Reading Agendula's tasks from another app +# Using Agendula's tasks from another app When Agendula keeps tasks in its own database (the default storage mode), it -publishes them through a read-only `ContentProvider` that follows the +publishes them through a `ContentProvider` that follows the [OpenTasks contract](https://github.com/dmfs/opentasks/blob/master/opentasks-contract/src/main/java/org/dmfs/tasks/contract/TaskContract.java). -An app that already reads OpenTasks or tasks.org can read Agendula by using a -different authority and permission. +An app that already works with OpenTasks or tasks.org can work with Agendula by +using a different authority and permissions. | | | |---|---| | Authority | `de.jeanlucmakiola.agendula.tasks` (offline build: `de.jeanlucmakiola.agendula.offline.tasks`) | -| Permission | `de.jeanlucmakiola.agendula.permission.READ_TASKS` (offline build: `de.jeanlucmakiola.agendula.offline.permission.READ_TASKS`) | -| Protection level | `dangerous`: request it at runtime | +| Read permission | `de.jeanlucmakiola.agendula.permission.READ_TASKS` (offline build: `de.jeanlucmakiola.agendula.offline.permission.READ_TASKS`) | +| Write permission | `de.jeanlucmakiola.agendula.permission.WRITE_TASKS` (offline build: `de.jeanlucmakiola.agendula.offline.permission.WRITE_TASKS`) | +| Protection level | `dangerous`: request them at runtime | | Package visibility | add `` to your `` | Both names are derived from the installed package name, so every build has its own and they can be installed side by side: the authority is -`.tasks` and the permission `.permission.READ_TASKS`. +`.tasks` and the permissions are `.permission.READ_TASKS` and +`.permission.WRITE_TASKS`. | Build | Package | |---|---| @@ -40,15 +42,72 @@ all-day dates as UTC midnight, `status`, `priority`, `class`) and MIME types are the OpenTasks ones. `selection`, `selectionArgs` and `sortOrder` work as they do against OpenTasks. +## Writing + +Writes need the write permission and go through the same code as edits made in +Agendula itself: a changed task is marked for the next CalDAV sync, reminders +are re-armed, and widgets refresh. + +| Table | insert | update | delete | +|---|---|---|---| +| `tasklists` | no | no | no | +| `tasks` | yes | yes | yes | +| `instances` | no | see below | one occurrence | +| `properties` | alarms and tags | alarms and tags | alarms and tags | + +`update` and `delete` take a `selection` like a query does, and it matches the +rows you can read. `insert` returns the new row's URI. `applyBatch` runs in one +transaction: if any operation fails, none of them is applied. A write that +changes nothing does not mark the task for sync. + +**Columns.** A task write may set `list_id`, `_uid` (on insert only), `title`, +`description`, `location`, `url`, `priority`, `class`, `status`, +`percent_complete`, `completed`, `task_color`, `dtstart`, `due`, `duration`, +`is_allday`, `tz`, `rrule`, `rdate`, `exdate`, `parent_id` and `sorting`. Other +columns the table publishes are ignored, so you can write back a row you read. +A column the table does not have is rejected with `IllegalArgumentException`. +Agendula's sync state (the server's URL and ETag, the iCalendar `SEQUENCE`, and +properties it does not understand) is not published and cannot be written. + +**Status.** `status`, `percent_complete` and `completed` stay consistent the way +OpenTasks keeps them. Completing a task sets 100 % and a completion time, +progress between 1 and 99 sets "in process", and a completion time alone +completes the task. + +**Recurring tasks.** Writing `status`, `percent_complete` or `completed` to an +`instances` row completes, cancels or reopens that one occurrence; partial +progress is rejected. Other content columns written to an instance change the +whole series, and that occurrence too if it had been modified on its own. +Changing a time or recurrence column (`dtstart`, `due`, `duration`, +`is_allday`, `tz`, `rrule`, `rdate`, `exdate`) is rejected on an instance of a +recurring task, so move a series through `tasks`; writing those columns back +unchanged, as in a row you read, is fine. Moving a series' start or changing its +rule drops the occurrences that had been modified on their own, as it does in +the app. Deleting an `instances` row deletes that one occurrence. + +**Subtasks.** A subtask has to be in its parent's list. Moving a task to another +list moves its subtasks with it, and deleting a task deletes them. A task in a +synced list stays as a hidden tombstone until the next sync removes it from the +server. + +**Rejected writes** throw `IllegalArgumentException` for a bad value, a missing +list or parent, or a parent in another list. They throw +`UnsupportedOperationException` for lists and for tasks in a list the server +has made read-only, and `IllegalStateException` while Agendula works on top of +an external provider or is still starting. `list_access_level` does not tell +you which lists are read-only: OpenTasks reserves it and asks providers to +publish `0`. + ## Behaviour -- **Read-only.** `insert`, `update` and `delete` throw - `UnsupportedOperationException`. - **Change notifications.** Register a `ContentObserver` on `content://` with `notifyForDescendants = true`. It fires when tasks change or when the user switches storage mode. - **External storage mode.** When the user has Agendula work on top of - OpenTasks or tasks.org, every table is empty. Read that provider directly. -- **Instance ids** are not stable: as in OpenTasks they are reassigned when the - data changes, and also hourly as the expansion window moves (observers are - notified then too). Key on `task_id` plus `instance_original_time`. + OpenTasks or tasks.org, every table is empty and writes are refused. Use that + provider directly. +- **Ids are stable.** A task keeps its `_id`. An instance's `_id` is built + from its series and its original time, and a property's from its alarm or + its task and tag, so they stay the same across reads and within a batch for + as long as that occurrence, alarm or tag exists. `distance_from_current` + still moves with the clock, and observers are notified hourly when it does. diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index a9e5162..7ae05d8 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -55,7 +55,8 @@ All of this is verifiable in the - **On your device (the default)** — your task lists, tasks and reminders are kept in Agendula's own database inside the app's private storage, and uninstalling the app removes it. Another app can read these tasks only if you - grant it Agendula's "read Agendula tasks" permission (see section 9). + grant it Agendula's "read Agendula tasks" permission, and change them only if + you grant it "add, change and delete Agendula tasks" (see section 9). - **In a tasks provider you already use** — OpenTasks or tasks.org. Agendula then reads and writes that app's task database through Android's provider mechanism, after you grant its read/write permission. Whatever already @@ -210,13 +211,20 @@ source: used for scheduled background work (WorkManager); on older Android versions it needs them to run an expedited sync. -Agendula declares one permission that other apps can request: -`de.jeanlucmakiola.agendula.permission.READ_TASKS`. Android asks you before -granting it, and you can revoke it at any time in the system settings. An app -that holds it can read your task lists, tasks, reminders and tags from -Agendula's own database (read-only, and nothing while Agendula works on top of -an external tasks provider). What that app then does with them is governed by -its own privacy policy. +Agendula declares two permissions that other apps can request. Android asks you +before granting either, and you can revoke them at any time in the system +settings. + +- `de.jeanlucmakiola.agendula.permission.READ_TASKS`: an app that holds it can + read your task lists, tasks, reminders and tags from Agendula's own database. +- `de.jeanlucmakiola.agendula.permission.WRITE_TASKS`: an app that holds it can + add, change, complete and delete those tasks, reminders and tags. It cannot + change or delete task lists. Its changes sync to your CalDAV server like your + own edits. + +Neither gives access to anything while Agendula works on top of an external +tasks provider. What an app does with your tasks is governed by its own privacy +policy. ## 10. Distribution channels