From e7bf23e276693277ae68e6d842a51ed45c681674 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 15 Oct 2026 13:25:48 +0200 Subject: [PATCH] Publish the own store through a read-only OpenTasks provider (#43) (#62) In Own storage mode, Agendula now publishes its Room store as a ContentProvider that follows the OpenTasks contract. It serves `tasklists`, `tasks`, `instances` and `properties` (reminders and tags) at `.tasks`, behind a dangerous-level `.permission.READ_TASKS` that the user grants per app. An app that already reads OpenTasks or tasks.org only has to change the authority and the permission. On a query, the provider lays the Room store out in OpenTasks' columns and loads that into an in-memory SQLite database, so a client's selection, arguments and sort order run as real SQL. The snapshot is rebuilt after a write, after a storage mode switch, or once it is an hour old, because instance ids and `distance_from_current` move with the clock. Observers on the authority get one notification per burst of changes. In External mode every table is empty. Recurrence expansion moved out of `RoomTasksDataSource` into shared functions, so the app and the provider expand a series the same way. `docs/CONTENT_PROVIDER.md` describes the contract for client developers, with the authority and permission for each build. README, CHANGELOG and the privacy policy mention the new permission. Where this differs from the issue: the issue asked for read and write permissions, and this PR is read-only. Insert, update and delete throw. Accepting writes from other apps means deciding what happens to the fields Agendula keeps verbatim for CalDAV round-tripping, which was the issue's second open question, and that needs its own design. That is tracked in #63. Closes #43 Co-authored-by: Jean-Luc Makiola Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/62 --- CHANGELOG.md | 6 + README.md | 4 + .../provider/AgendulaTaskProviderTest.kt | 112 ++++++ .../provider/ProviderSnapshotSourceTest.kt | 214 ++++++++++++ .../tasks/provider/SnapshotDatabaseTest.kt | 141 ++++++++ app/src/main/AndroidManifest.xml | 21 +- .../de/jeanlucmakiola/agendula/AgendulaApp.kt | 3 + .../agendula/data/tasks/TasksContract.kt | 14 + .../tasks/provider/AgendulaTaskProvider.kt | 78 +++++ .../data/tasks/provider/ProviderSchema.kt | 116 +++++++ .../data/tasks/provider/ProviderSnapshot.kt | 209 +++++++++++ .../tasks/provider/ProviderSnapshotSource.kt | 137 ++++++++ .../data/tasks/provider/SnapshotDatabase.kt | 88 +++++ .../data/tasks/room/RoomTasksDataSource.kt | 91 +++-- .../agendula/data/tasks/room/TaskDao.kt | 4 + app/src/main/res/values/strings.xml | 3 + .../tasks/provider/ProviderSnapshotTest.kt | 326 ++++++++++++++++++ .../data/tasks/provider/ProviderTableTest.kt | 70 ++++ docs/CONTENT_PROVIDER.md | 54 +++ docs/PRIVACY.md | 18 +- 20 files changed, 1666 insertions(+), 43 deletions(-) create mode 100644 app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/provider/AgendulaTaskProviderTest.kt create mode 100644 app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderSnapshotSourceTest.kt create mode 100644 app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/provider/SnapshotDatabaseTest.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/AgendulaTaskProvider.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderSchema.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderSnapshot.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderSnapshotSource.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/SnapshotDatabase.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderSnapshotTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderTableTest.kt create mode 100644 docs/CONTENT_PROVIDER.md diff --git a/CHANGELOG.md b/CHANGELOG.md index afb1971..4b9e27d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +### Added +- Other apps can read the tasks kept in Agendula's own database through an + 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). + ## [1.1.0] - 2026-10-10 ### Added diff --git a/README.md b/README.md index 0343709..d7ee1f8 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,10 @@ 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 +OpenTasks-compatible provider, once you grant them permission. See +[docs/CONTENT_PROVIDER.md](docs/CONTENT_PROVIDER.md). + Switching between the two moves nothing — each store keeps its own tasks — so **Settings → Storage** asks before it switches, and offers to **copy** a provider's tasks into Agendula's own store when you want to move over. The copy 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 new file mode 100644 index 0000000..132849b --- /dev/null +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/provider/AgendulaTaskProviderTest.kt @@ -0,0 +1,112 @@ +package de.jeanlucmakiola.agendula.data.tasks.provider + +import android.content.ContentValues +import android.content.Context +import android.content.pm.PermissionInfo +import android.os.Build +import android.os.ParcelFileDescriptor +import androidx.core.net.toUri +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 org.junit.Assert.assertThrows +import org.junit.Assume.assumeTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** The provider as another app meets it: manifest, permission, URIs. */ +@RunWith(AndroidJUnit4::class) +class AgendulaTaskProviderTest { + + private val context = ApplicationProvider.getApplicationContext() + private val authority = "${context.packageName}.tasks" + private val permission = "${context.packageName}.permission.READ_TASKS" + private val resolver = context.contentResolver + + private fun uri(path: String) = "content://$authority/$path".toUri() + + @Test + fun isExportedUnderItsPermission() { + 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) + } + + @Test + fun thePermissionIsDangerous() { + val info = context.packageManager.getPermissionInfo(permission, 0) + + assertThat(info.protection).isEqualTo(PermissionInfo.PROTECTION_DANGEROUS) + assertThat(info.loadLabel(context.packageManager).toString()).isNotEmpty() + } + + @Test + fun anAppWithoutThePermissionIsDenied() { + assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) + // The shell is another uid that was never granted it. + val output = shell("content query --uri content://$authority/tasks") + + assertThat(output).contains("Permission Denial") + } + + @Test + fun reportsOpenTasksMimeTypes() { + ProviderTable.entries.forEach { table -> + assertThat(resolver.getType(uri(table.path))).isEqualTo(table.mimeType(single = false)) + assertThat(resolver.getType(uri("${table.path}/3"))).isEqualTo(table.mimeType(single = true)) + } + } + + @Test + fun everyTableAnswersWithItsColumns() { + ProviderTable.entries.forEach { table -> + resolver.query(uri(table.path), null, null, null, null)!!.use { + assertThat(it.columnNames.toList()).containsExactlyElementsIn(table.columns.map { c -> c.first }).inOrder() + } + } + } + + @Test + fun aCursorWatchesTheUriItWasQueriedFor() { + val tasks = uri("tasks") + + resolver.query(tasks, null, null, null, null)!!.use { assertThat(it.notificationUri).isEqualTo(tasks) } + } + + @Test + fun aRowUriReadsOnlyThatRow() { + // Ids start at 1, so 0 names nothing whatever the store holds. + resolver.query(uri("tasks/0"), null, null, null, null)!!.use { assertThat(it.count).isEqualTo(0) } + resolver.query(uri("tasks/0"), null, "1 = 1 OR 1 = 1", null, null)!!.use { assertThat(it.count).isEqualTo(0) } + } + + @Test + fun anUnknownUriIsRejected() { + assertThrows(IllegalArgumentException::class.java) { resolver.query(uri("events"), null, null, null, null) } + // ContentResolver.getType swallows the provider's exception and answers null. + assertThat(runCatching { resolver.getType(uri("tasks/abc")) }.getOrNull()).isNull() + } + + @Test + fun writesAreRefused() { + val values = ContentValues().apply { put("title", "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) } + } + + /** stdout and stderr of [command]; the denial lands on stderr. */ + private fun shell(command: String): String { + val (stdout, stdin, stderr) = InstrumentationRegistry.getInstrumentation().uiAutomation + .executeShellCommandRwe(command) + stdin.close() + return listOf(stdout, stderr).joinToString("\n") { pfd -> + ParcelFileDescriptor.AutoCloseInputStream(pfd).bufferedReader().use { it.readText() } + } + } +} diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderSnapshotSourceTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderSnapshotSourceTest.kt new file mode 100644 index 0000000..0d50754 --- /dev/null +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderSnapshotSourceTest.kt @@ -0,0 +1,214 @@ +package de.jeanlucmakiola.agendula.data.tasks.provider + +import android.content.Context +import android.database.ContentObserver +import android.database.Cursor +import android.net.Uri +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.Instances +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks +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 org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit +import kotlin.time.Clock +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Instant + +/** The snapshot follows the Room store, the storage mode and the clock, and says when it moved. */ +@RunWith(AndroidJUnit4::class) +class ProviderSnapshotSourceTest { + + private val context = ApplicationProvider.getApplicationContext() + private lateinit var db: TasksDatabase + private lateinit var resolver: ProviderResolver + private lateinit var source: ProviderSnapshotSource + private val clock = MutableClock(Instant.parse("2026-10-10T08:00:00Z")) + private val changes = LinkedBlockingQueue() + private val observer = object : ContentObserver(null) { + override fun onChange(selfChange: Boolean, uri: Uri?) { + changes += uri ?: Uri.EMPTY + } + } + private var listId = 0L + + @Before + fun setUp() { + db = Room.inMemoryDatabaseBuilder(context, TasksDatabase::class.java).allowMainThreadQueries().build() + resolver = ProviderResolver(NoProviderInstalled) + source = ProviderSnapshotSource(context, db, resolver, awaitStartup = {}, clock = clock) + listId = db.taskLists().insert(TaskListEntity(name = "Home", color = 0xFF112233.toInt())) + context.contentResolver.registerContentObserver(TasksContract.authorityUri(source.authority), true, observer) + } + + @After + fun tearDown() { + context.contentResolver.unregisterContentObserver(observer) + db.close() + } + + private fun query(table: ProviderTable, selection: String? = null, args: Array? = null): Cursor = + source.query(table, null, selection, args, null) + + private fun titles(): List = query(ProviderTable.TASKS).use { cursor -> + val index = cursor.getColumnIndexOrThrow(Tasks.TITLE) + buildList { while (cursor.moveToNext()) add(cursor.getString(index)) } + } + + private fun insert(title: String, due: Instant? = null, rrule: String? = null): Long = + db.tasks().insert(TaskEntity(listId = listId, uid = title, title = title, due = due, rrule = rrule, timezone = "UTC")) + + /** Waits for the coalesced notification, then drains any stragglers. */ + private fun awaitChange() { + assertThat(changes.poll(5, TimeUnit.SECONDS)).isNotNull() + Thread.sleep(700) + changes.clear() + } + + private fun assertNoChange() { + assertThat(changes.poll(1500, TimeUnit.MILLISECONDS)).isNull() + } + + @Test + fun servesTheOwnStore() { + val id = insert("Milk") + db.alarms().insert(TaskAlarmEntity(taskId = id, minutesBefore = 10)) + + assertThat(titles()).containsExactly("Milk") + query(ProviderTable.LISTS).use { + it.moveToFirst() + assertThat(it.getString(it.getColumnIndexOrThrow(Lists.NAME))).isEqualTo("Home") + } + query(ProviderTable.INSTANCES).use { assertThat(it.count).isEqualTo(1) } + query(ProviderTable.PROPERTIES).use { assertThat(it.count).isEqualTo(1) } + } + + @Test + fun aDeletedTaskIsNotPublished() { + val id = insert("Gone") + insert("Kept") + db.tasks().markDeleted(id, clock.now()) + + assertThat(titles()).containsExactly("Kept") + } + + @Test + fun aWriteNotifiesObserversAndIsServedNext() { + source.start() + assertThat(titles()).isEmpty() + + insert("Later") + + awaitChange() + assertThat(titles()).containsExactly("Later") + } + + @Test + fun aBurstOfWritesNotifiesOnce() { + source.start() + titles() + + repeat(5) { insert("t$it") } + + assertThat(changes.poll(5, TimeUnit.SECONDS)).isNotNull() + assertNoChange() + } + + @Test + fun externalModeServesNothing() { + insert("Mine") + resolver.storageMode = StorageMode.EXTERNAL + + ProviderTable.entries.forEach { table -> query(table).use { assertThat(it.count).isEqualTo(0) } } + } + + @Test + fun switchingModeNotifiesAndRebuilds() { + source.start() + insert("Mine") + assertThat(titles()).containsExactly("Mine") + changes.clear() + + resolver.storageMode = StorageMode.EXTERNAL + awaitChange() + assertThat(titles()).isEmpty() + + resolver.storageMode = StorageMode.OWN + awaitChange() + assertThat(titles()).containsExactly("Mine") + } + + @Test + fun writesInExternalModeDoNotNotify() { + resolver.storageMode = StorageMode.EXTERNAL + source.start() + titles() + + insert("Hidden") + + assertNoChange() + } + + @Test + fun aClientSelectionNarrowsTheRows() { + insert("a") + insert("b") + + query(ProviderTable.TASKS, "${Tasks.TITLE} = ?", arrayOf("b")).use { assertThat(it.count).isEqualTo(1) } + } + + @Test + fun anHourOldSnapshotIsRebuiltAgainstTheClock() { + source.start() + insert("Daily", due = Instant.parse("2026-10-10T09:00:00Z"), rrule = "FREQ=DAILY;COUNT=3") + awaitChange() + assertThat(currentOriginalTime()).isEqualTo(Instant.parse("2026-10-10T09:00:00Z").toEpochMilliseconds()) + + clock.current += 1.days + + assertThat(currentOriginalTime()).isEqualTo(Instant.parse("2026-10-11T09:00:00Z").toEpochMilliseconds()) + awaitChange() + } + + @Test + fun aFreshSnapshotIsReused() { + insert("a") + titles() + + clock.current += 30.minutes + + query(ProviderTable.INSTANCES).use { assertThat(it.count).isEqualTo(1) } + assertNoChange() + } + + private fun currentOriginalTime(): Long = + query(ProviderTable.INSTANCES, "${Instances.DISTANCE_FROM_CURRENT} = 0").use { + assertThat(it.moveToFirst()).isTrue() + it.getLong(it.getColumnIndexOrThrow(Instances.INSTANCE_ORIGINAL_TIME)) + } +} + +private class MutableClock(var current: Instant) : Clock { + override fun now(): Instant = current +} + +private object NoProviderInstalled : 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/provider/SnapshotDatabaseTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/provider/SnapshotDatabaseTest.kt new file mode 100644 index 0000000..c669613 --- /dev/null +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/provider/SnapshotDatabaseTest.kt @@ -0,0 +1,141 @@ +package de.jeanlucmakiola.agendula.data.tasks.provider + +import android.database.Cursor +import android.os.Build +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks +import org.junit.Assert.assertThrows +import org.junit.Assume.assumeTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** The SQL a client sends runs against the snapshot as it would against OpenTasks. */ +@RunWith(AndroidJUnit4::class) +class SnapshotDatabaseTest { + + private val db = SnapshotDatabase() + + private fun task(id: Long, title: String?, due: Long? = null): ProviderRow = + mapOf(Tasks.ID to id, Tasks.LIST_ID to 1L, Tasks.TITLE to title, Tasks.DUE to due, Tasks.PRIORITY to 0) + + private fun load(vararg tasks: ProviderRow, lists: List = emptyList()) = db.load( + ProviderSnapshot( + ProviderTable.entries.associateWith { + when (it) { + ProviderTable.TASKS -> tasks.toList() + ProviderTable.LISTS -> lists + else -> emptyList() + } + }, + ), + ) + + private fun Cursor.strings(column: String): List = use { + val index = getColumnIndexOrThrow(column) + buildList { while (moveToNext()) add(if (isNull(index)) null else getString(index)) } + } + + @Test + fun aNullProjectionReturnsEveryColumnInTableOrder() { + load(task(1, "a")) + + db.query(ProviderTable.TASKS, null, null, null, null).use { + assertThat(it.columnNames.toList()).containsExactlyElementsIn(ProviderTable.TASKS.columns.map { c -> c.first }) + .inOrder() + assertThat(it.count).isEqualTo(1) + } + } + + @Test + fun selectionArgumentsAndSortOrderApply() { + load(task(1, "b", due = 30), task(2, "a", due = 10), task(3, "c", due = 20), task(4, "skip", due = 5)) + + val titles = db.query(ProviderTable.TASKS, arrayOf(Tasks.TITLE), "${Tasks.TITLE} != ?", arrayOf("skip"), "${Tasks.DUE} ASC") + .strings(Tasks.TITLE) + + assertThat(titles).containsExactly("a", "c", "b").inOrder() + } + + @Test + fun typesSurviveTheCopy() { + load(task(1, null, due = 1_700_000_000_000)) + + db.query(ProviderTable.TASKS, arrayOf(Tasks.TITLE, Tasks.DUE, Tasks.PRIORITY), null, null, null).use { + it.moveToFirst() + assertThat(it.isNull(0)).isTrue() + assertThat(it.getType(1)).isEqualTo(Cursor.FIELD_TYPE_INTEGER) + assertThat(it.getLong(1)).isEqualTo(1_700_000_000_000) + assertThat(it.getInt(2)).isEqualTo(0) + } + } + + @Test + fun anUnsupportedColumnReadsAsNull() { + load(task(1, "a")) + + assertThat(db.query(ProviderTable.TASKS, arrayOf("_sync_id"), null, null, null).strings("_sync_id")) + .containsExactly(null) + } + + @Test + fun anUnknownProjectionColumnIsRejected() { + load(task(1, "a")) + + assertThrows(IllegalArgumentException::class.java) { + db.query(ProviderTable.TASKS, arrayOf("no_such_column"), null, null, null) + } + } + + @Test + fun anUnknownSelectionColumnIsRejected() { + assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) + load(task(1, "a")) + + assertThrows(IllegalArgumentException::class.java) { + db.query(ProviderTable.TASKS, null, "sqlite_master.name IS NOT NULL", null, null) + } + } + + @Test + fun aSelectionCannotBreakOutOfItsWhereClause() { + load(task(1, "a")) + + assertThrows(IllegalArgumentException::class.java) { + db.query(ProviderTable.TASKS, null, "1) UNION SELECT name FROM sqlite_master --", null, null) + } + } + + @Test + fun eachTableAnswersOnlyForItsOwnRows() { + load(task(1, "a"), lists = listOf(mapOf(Lists.ID to 7L, Lists.NAME to "Home"))) + + assertThat(db.query(ProviderTable.LISTS, arrayOf(Lists.NAME), null, null, null).strings(Lists.NAME)) + .containsExactly("Home") + db.query(ProviderTable.INSTANCES, null, null, null, null).use { assertThat(it.count).isEqualTo(0) } + } + + @Test + fun aReloadReplacesTheRowsButNotAnOpenCursor() { + load(task(1, "old")) + val before = db.query(ProviderTable.TASKS, arrayOf(Tasks.TITLE), null, null, null) + + load(task(2, "new")) + + assertThat(before.strings(Tasks.TITLE)).containsExactly("old") + assertThat(db.query(ProviderTable.TASKS, arrayOf(Tasks.TITLE), null, null, null).strings(Tasks.TITLE)) + .containsExactly("new") + } + + @Test + fun anEmptySnapshotEmptiesEveryTable() { + load(task(1, "a")) + + db.load(ProviderSnapshot.EMPTY) + + ProviderTable.entries.forEach { table -> + db.query(table, null, null, null, null).use { assertThat(it.count).isEqualTo(0) } + } + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c2fc549..1ccc783 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -9,13 +9,21 @@ 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. --> + directory. --> + + + + + diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/AgendulaApp.kt b/app/src/main/java/de/jeanlucmakiola/agendula/AgendulaApp.kt index 08bb3e4..566b63b 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/AgendulaApp.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/AgendulaApp.kt @@ -16,6 +16,7 @@ import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler import de.jeanlucmakiola.agendula.data.reminders.TaskNotifier import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver import de.jeanlucmakiola.agendula.data.tasks.StartupGate +import de.jeanlucmakiola.agendula.data.tasks.provider.ProviderSnapshotSource import de.jeanlucmakiola.agendula.data.tasks.room.DatabaseCheckpoint import de.jeanlucmakiola.agendula.widget.TaskWidgetUpdater import de.jeanlucmakiola.floret.crash.CrashConfig @@ -87,6 +88,7 @@ class AgendulaApp : Application(), Configuration.Provider { scheduler.sync() } runCatching { entryPoint.taskWidgetUpdater().start() } + runCatching { entryPoint.providerSnapshotSource().start() } runCatching { ReminderMaintenanceWorker.schedule(this@AgendulaApp) } } } @@ -122,5 +124,6 @@ class AgendulaApp : Application(), Configuration.Provider { fun taskNotifier(): TaskNotifier fun channelRefreshers(): Set<@JvmSuppressWildcards ChannelRefresher> fun taskWidgetUpdater(): TaskWidgetUpdater + fun providerSnapshotSource(): ProviderSnapshotSource } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksContract.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksContract.kt index 75f68ee..f15489a 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksContract.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksContract.kt @@ -86,6 +86,12 @@ object TasksContract { const val UID = "_uid" const val DELETED = "_deleted" + + const val HAS_ALARMS = "has_alarms" + const val ORIGINAL_INSTANCE_ALLDAY = "original_instance_allday" + const val LIST_OWNER = "list_owner" + const val LIST_ACCESS_LEVEL = "list_access_level" + const val VISIBLE = "visible" } /** @@ -103,6 +109,7 @@ object TasksContract { const val INSTANCE_DUE_SORTING = "instance_due_sorting" const val DISTANCE_FROM_CURRENT = "distance_from_current" const val IS_RECURRING = "is_recurring" + const val INSTANCE_DURATION = "instance_duration" /** * The occurrence's `RECURRENCE-ID` — the time this occurrence was @@ -118,6 +125,10 @@ object TasksContract { const val PROPERTY_ID = "property_id" const val TASK_ID = "task_id" const val MIMETYPE = "mimetype" + const val DATA0 = "data0" + const val DATA1 = "data1" + const val DATA2 = "data2" + const val DATA3 = "data3" } /** @@ -164,6 +175,9 @@ object TasksContract { /** `data1` — the tag as spelled. */ const val NAME = "data1" + + /** `data2` — the tag's colour, if any. */ + const val COLOR = "data2" } fun propertiesUri(authority: String): Uri = "content://$authority/${Properties.PATH}".toUri() diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/AgendulaTaskProvider.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/AgendulaTaskProvider.kt new file mode 100644 index 0000000..43a0644 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/AgendulaTaskProvider.kt @@ -0,0 +1,78 @@ +package de.jeanlucmakiola.agendula.data.tasks.provider + +import android.content.ContentProvider +import android.content.ContentValues +import android.content.UriMatcher +import android.database.Cursor +import android.net.Uri +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent + +/** + * Agendula's own store, published read-only under the OpenTasks contract + * (`tasklists`, `tasks`, `instances`, `properties`) at `.tasks`, + * so an app that already reads OpenTasks or tasks.org reads Agendula by + * swapping the authority. Guarded by `.permission.READ_TASKS` + * in the manifest. See `docs/CONTENT_PROVIDER.md`. + */ +class AgendulaTaskProvider : ContentProvider() { + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface ProviderEntryPoint { + fun snapshotSource(): ProviderSnapshotSource + } + + private val source: ProviderSnapshotSource by lazy { + EntryPointAccessors.fromApplication(requireNotNull(context), ProviderEntryPoint::class.java).snapshotSource() + } + + private val matcher by lazy { + UriMatcher(UriMatcher.NO_MATCH).apply { + ProviderTable.entries.forEach { table -> + addURI(source.authority, table.path, table.ordinal * 2) + addURI(source.authority, "${table.path}/#", table.ordinal * 2 + 1) + } + } + } + + override fun onCreate(): Boolean = true + + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String?, + ): Cursor { + val (table, id) = match(uri) + return source.query(table, projection, table.selectionFor(id, selection), selectionArgs, sortOrder).apply { + setNotificationUri(requireNotNull(context).contentResolver, uri) + } + } + + override fun getType(uri: Uri): String { + val (table, id) = match(uri) + return table.mimeType(single = id != null) + } + + override fun insert(uri: Uri, values: ContentValues?): Uri = readOnly() + + override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?): Int = + readOnly() + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = readOnly() + + private fun match(uri: Uri): Pair { + val code = matcher.match(uri) + require(code != UriMatcher.NO_MATCH) { "Unknown URI $uri" } + val table = ProviderTable.entries[code / 2] + if (code % 2 == 0) return table to null + val id = requireNotNull(uri.lastPathSegment?.toLongOrNull()) { "Unknown URI $uri" } + return table to id + } + + private fun readOnly(): Nothing = throw UnsupportedOperationException("Agendula's task provider is read-only") +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderSchema.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderSchema.kt new file mode 100644 index 0000000..1f905f3 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderSchema.kt @@ -0,0 +1,116 @@ +package de.jeanlucmakiola.agendula.data.tasks.provider + +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 + +/** One table [AgendulaTaskProvider] serves, with its columns in cursor order. */ +enum class ProviderTable(val path: String, val columns: List>) { + LISTS( + Lists.PATH, + listOf( + Lists.ID to "INTEGER PRIMARY KEY", + Lists.NAME to "TEXT", + Lists.COLOR to "INTEGER", + Lists.ACCESS_LEVEL to "INTEGER", + Lists.VISIBLE to "INTEGER", + Lists.SYNC_ENABLED to "INTEGER", + Lists.OWNER to "TEXT", + Lists.ACCOUNT_NAME to "TEXT", + Lists.ACCOUNT_TYPE to "TEXT", + ), + ), + TASKS(Tasks.PATH, listOf(Tasks.ID to "INTEGER PRIMARY KEY") + TASK_COLUMNS), + INSTANCES( + Instances.PATH, + listOf( + Tasks.ID to "INTEGER PRIMARY KEY", + Instances.TASK_ID to "INTEGER", + Instances.INSTANCE_START to "INTEGER", + Instances.INSTANCE_START_SORTING to "INTEGER", + Instances.INSTANCE_DUE to "INTEGER", + Instances.INSTANCE_DUE_SORTING to "INTEGER", + Instances.INSTANCE_DURATION to "INTEGER", + Instances.INSTANCE_ORIGINAL_TIME to "INTEGER", + Instances.DISTANCE_FROM_CURRENT to "INTEGER", + Instances.IS_RECURRING to "INTEGER", + ) + TASK_COLUMNS, + ), + PROPERTIES( + Properties.PATH, + listOf( + Properties.PROPERTY_ID to "INTEGER PRIMARY KEY", + Properties.TASK_ID to "INTEGER", + Properties.MIMETYPE to "TEXT", + Properties.DATA0 to "TEXT", + Properties.DATA1 to "TEXT", + Properties.DATA2 to "TEXT", + Properties.DATA3 to "TEXT", + ), + ), + ; + + val idColumn: String get() = columns.first().first + + /** [selection] narrowed to the row [id] names, for a `/` URI. */ + fun selectionFor(id: Long?, selection: String?): String? = + if (id == null) selection else listOfNotNull("$idColumn = $id", selection?.let { "($it)" }).joinToString(" AND ") + + /** OpenTasks' MIME type for a whole table, or for one row of it. */ + fun mimeType(single: Boolean): String = + "${if (single) "vnd.android.cursor.item" else "vnd.android.cursor.dir"}/org.dmfs.tasks.$path" +} + +/** Every `tasks` column but `_id`; the instances view carries them too. */ +private val TASK_COLUMNS: List> + get() = listOf( + Tasks.LIST_ID to "INTEGER", + Tasks.LIST_NAME to "TEXT", + Tasks.LIST_COLOR to "INTEGER", + Tasks.LIST_OWNER to "TEXT", + Tasks.LIST_ACCESS_LEVEL to "INTEGER", + Tasks.VISIBLE to "INTEGER", + Tasks.ACCOUNT_NAME to "TEXT", + Tasks.ACCOUNT_TYPE to "TEXT", + Tasks.UID to "TEXT", + Tasks.DELETED to "INTEGER", + Tasks.TITLE to "TEXT", + Tasks.DESCRIPTION to "TEXT", + Tasks.LOCATION to "TEXT", + Tasks.URL to "TEXT", + Tasks.PRIORITY to "INTEGER", + Tasks.CLASSIFICATION to "INTEGER", + Tasks.STATUS to "INTEGER", + Tasks.PERCENT_COMPLETE to "INTEGER", + Tasks.COMPLETED to "INTEGER", + Tasks.IS_CLOSED to "INTEGER", + Tasks.IS_NEW to "INTEGER", + Tasks.TASK_COLOR to "INTEGER", + Tasks.DTSTART to "INTEGER", + Tasks.DUE to "INTEGER", + Tasks.DURATION to "TEXT", + Tasks.IS_ALLDAY to "INTEGER", + Tasks.TZ to "TEXT", + Tasks.RRULE to "TEXT", + Tasks.RDATE to "TEXT", + Tasks.EXDATE to "TEXT", + Tasks.ORIGINAL_INSTANCE_ID to "INTEGER", + Tasks.ORIGINAL_INSTANCE_TIME to "INTEGER", + Tasks.ORIGINAL_INSTANCE_ALLDAY to "INTEGER", + Tasks.PARENT_ID to "INTEGER", + Tasks.SORTING to "INTEGER", + Tasks.HAS_ALARMS to "INTEGER", + Tasks.CREATED to "INTEGER", + Tasks.LAST_MODIFIED to "INTEGER", + ) + UNSUPPORTED_TASK_COLUMNS.map { it to "TEXT" } + +/** + * OpenTasks columns Agendula has nothing for. Published as `NULL` so a client + * that projects them reads nothing rather than failing the query. + */ +private val UNSUPPORTED_TASK_COLUMNS = listOf( + "geo", "organizer", "pinned", "has_properties", "completed_is_allday", "original_instance_sync_id", + "version", "_dirty", "_sync_id", "sync_version", + "sync1", "sync2", "sync3", "sync4", "sync5", "sync6", "sync7", "sync8", +) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderSnapshot.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderSnapshot.kt new file mode 100644 index 0000000..0c0d1fc --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderSnapshot.kt @@ -0,0 +1,209 @@ +package de.jeanlucmakiola.agendula.data.tasks.provider + +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.Lists +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks +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.TaskListRow +import de.jeanlucmakiola.agendula.data.tasks.room.seriesOccurrences +import de.jeanlucmakiola.agendula.domain.TaskStatus +import de.jeanlucmakiola.agendula.domain.toInt +import java.time.ZoneId +import kotlin.time.Instant + +typealias ProviderRow = Map + +/** The Room store laid out as OpenTasks' tables, ready to be queried. */ +class ProviderSnapshot(val rows: Map>) { + + companion object { + val EMPTY = ProviderSnapshot(ProviderTable.entries.associateWith { emptyList() }) + + /** + * Lays out the store. [tasks] must hold no tombstones. [accountType] is + * what a list attached to an account reports; a device-only list reports + * OpenTasks' local account so clients treat it the way they already do. + */ + fun build( + lists: List, + tasks: List, + alarms: List, + accountType: String, + now: Instant, + zone: ZoneId = ZoneId.systemDefault(), + ): ProviderSnapshot { + val listsById = lists.associateBy { it.list.id } + val alarmsByTask = alarms.groupBy { it.taskId } + val taskRows = tasks.mapNotNull { task -> + val list = listsById[task.listId] ?: return@mapNotNull null + task.id to taskColumns(task, list, alarmsByTask[task.id].orEmpty().isNotEmpty(), accountType) + }.toMap() + + val overrides = tasks.filter { it.masterId != null }.groupBy { it.masterId } + var instanceId = 0L + val instances = tasks.filter { it.masterId == null && it.id in taskRows }.flatMap { task -> + occurrences(task, overrides[task.id].orEmpty(), now).mapNotNull { occurrence -> + val columns = taskRows[occurrence.row.id] ?: return@mapNotNull null + columns + instanceColumns(++instanceId, occurrence, zone) + } + } + + return ProviderSnapshot( + mapOf( + ProviderTable.LISTS to lists.map { listColumns(it, accountType) }, + ProviderTable.TASKS to taskRows.map { (id, columns) -> columns + (Tasks.ID to id) }, + ProviderTable.INSTANCES to instances, + ProviderTable.PROPERTIES to properties(tasks.filter { it.id in taskRows }, alarms), + ), + ) + } + + private fun listColumns(row: TaskListRow, accountType: String): ProviderRow = mapOf( + Lists.ID to row.list.id, + Lists.NAME to row.list.name, + Lists.COLOR to row.list.color, + Lists.ACCESS_LEVEL to 0, + Lists.VISIBLE to row.list.isVisible.bit, + Lists.SYNC_ENABLED to row.list.isSynced.bit, + Lists.OWNER to row.list.owner, + Lists.ACCOUNT_NAME to accountName(row), + Lists.ACCOUNT_TYPE to accountTypeOf(row, accountType), + ) + + private fun taskColumns(task: TaskEntity, list: TaskListRow, hasAlarms: Boolean, accountType: String) = + mapOf( + Tasks.LIST_ID to task.listId, + Tasks.LIST_NAME to list.list.name, + Tasks.LIST_COLOR to list.list.color, + Tasks.LIST_OWNER to list.list.owner, + Tasks.LIST_ACCESS_LEVEL to 0, + Tasks.VISIBLE to list.list.isVisible.bit, + Tasks.ACCOUNT_NAME to accountName(list), + Tasks.ACCOUNT_TYPE to accountTypeOf(list, accountType), + Tasks.UID to task.uid, + Tasks.DELETED to 0, + Tasks.TITLE to task.title, + Tasks.DESCRIPTION to task.description, + Tasks.LOCATION to task.location, + Tasks.URL to task.url, + Tasks.PRIORITY to task.priority, + Tasks.CLASSIFICATION to task.classification, + Tasks.STATUS to task.status.toInt(), + Tasks.PERCENT_COMPLETE to task.percentComplete, + Tasks.COMPLETED to task.completedAt?.toEpochMilliseconds(), + Tasks.IS_CLOSED to (task.status == TaskStatus.COMPLETED || task.status == TaskStatus.CANCELLED).bit, + Tasks.IS_NEW to (task.status == TaskStatus.NEEDS_ACTION && task.percentComplete == null).bit, + Tasks.TASK_COLOR to task.color, + Tasks.DTSTART to task.dtstart?.toEpochMilliseconds(), + Tasks.DUE to task.due?.toEpochMilliseconds(), + Tasks.DURATION to task.duration, + Tasks.IS_ALLDAY to task.isAllDay.bit, + Tasks.TZ to task.timezone, + Tasks.RRULE to task.rrule, + Tasks.RDATE to task.rdate, + Tasks.EXDATE to task.exdate, + Tasks.ORIGINAL_INSTANCE_ID to task.masterId, + Tasks.ORIGINAL_INSTANCE_TIME to task.recurrenceId?.toEpochMilliseconds(), + Tasks.ORIGINAL_INSTANCE_ALLDAY to task.masterId?.let { task.isAllDay.bit }, + Tasks.PARENT_ID to task.parentId, + Tasks.SORTING to task.sortOrder, + Tasks.HAS_ALARMS to hasAlarms.bit, + Tasks.CREATED to task.createdAt?.toEpochMilliseconds(), + Tasks.LAST_MODIFIED to task.lastModified?.toEpochMilliseconds(), + ) + + /** One instance: the row it reads from, its times, and where it sits in its series. */ + private class Occurrence( + val row: TaskEntity, + val start: Instant?, + val due: Instant?, + val originalTime: Instant?, + val distance: Int, + val recurring: Boolean, + ) + + private fun occurrences(master: TaskEntity, overrides: List, now: Instant): List { + val series = seriesOccurrences(master, overrides, now) + ?: return listOf(Occurrence(master, master.dtstart, master.due, null, 0, recurring = false)) + return series.mapIndexed { index, occurrence -> + Occurrence( + row = occurrence.override ?: master, + start = occurrence.start, + due = occurrence.due, + originalTime = occurrence.anchor, + distance = occurrence.distance ?: index, + recurring = true, + ) + } + } + + private fun instanceColumns(id: Long, occurrence: Occurrence, zone: ZoneId): ProviderRow { + val allDay = occurrence.row.isAllDay + val start = occurrence.start?.toEpochMilliseconds() + val due = occurrence.due?.toEpochMilliseconds() + return mapOf( + Tasks.ID to id, + Instances.TASK_ID to occurrence.row.id, + Instances.INSTANCE_START to start, + Instances.INSTANCE_START_SORTING to start?.let { sorting(it, allDay, zone) }, + Instances.INSTANCE_DUE to due, + Instances.INSTANCE_DUE_SORTING to due?.let { sorting(it, allDay, zone) }, + Instances.INSTANCE_DURATION to if (start != null && due != null) due - start else null, + Instances.INSTANCE_ORIGINAL_TIME to occurrence.originalTime?.toEpochMilliseconds(), + Instances.DISTANCE_FROM_CURRENT to occurrence.distance, + Instances.IS_RECURRING to occurrence.recurring.bit, + ) + } + + /** + * An all-day date is stored as UTC midnight; OpenTasks' sorting columns + * move it to local midnight so it sorts among the timed tasks of that day. + */ + private fun sorting(millis: Long, allDay: Boolean, zone: ZoneId): Long { + if (!allDay) return millis + val offset = zone.rules.getOffset(java.time.Instant.ofEpochMilli(millis)).totalSeconds * 1000L + return millis - offset + } + + private fun properties(tasks: List, alarms: List): List { + val live = tasks.mapTo(HashSet()) { it.id } + val alarmRows = alarms.filter { it.taskId in live }.map { + mapOf( + Properties.TASK_ID to it.taskId, + Properties.MIMETYPE to Alarm.MIMETYPE, + Alarm.MINUTES_BEFORE to it.minutesBefore.toString(), + Alarm.REFERENCE to when (it.reference) { + AlarmReference.DUE -> Alarm.REFERENCE_DUE + AlarmReference.START -> Alarm.REFERENCE_START + }.toString(), + Alarm.MESSAGE to it.message, + Alarm.ALARM_TYPE to Alarm.TYPE_MESSAGE.toString(), + ) + } + val categoryRows = tasks.flatMap { task -> + task.categories.map { + mapOf( + Properties.TASK_ID to task.id, + Properties.MIMETYPE to Category.MIMETYPE, + Category.NAME to it, + ) + } + } + return (alarmRows + categoryRows).mapIndexed { index, row -> row + (Properties.PROPERTY_ID to index + 1L) } + } + + private fun accountName(row: TaskListRow): String = + row.accountDisplayName ?: TasksContract.LOCAL_ACCOUNT_NAME + + private fun accountTypeOf(row: TaskListRow, accountType: String): String = + if (row.list.accountId == null) TasksContract.LOCAL_ACCOUNT_TYPE else accountType + + private val Boolean.bit: Int get() = if (this) 1 else 0 + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderSnapshotSource.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderSnapshotSource.kt new file mode 100644 index 0000000..fc2a520 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderSnapshotSource.kt @@ -0,0 +1,137 @@ +package de.jeanlucmakiola.agendula.data.tasks.provider + +import android.content.Context +import android.database.Cursor +import android.os.Handler +import android.os.Looper +import androidx.room.InvalidationTracker +import dagger.hilt.android.qualifiers.ApplicationContext +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver +import de.jeanlucmakiola.agendula.data.tasks.StartupGate +import de.jeanlucmakiola.agendula.data.tasks.StorageMode +import de.jeanlucmakiola.agendula.data.tasks.TasksContract +import de.jeanlucmakiola.agendula.data.tasks.room.OBSERVED_TABLES +import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity +import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity +import de.jeanlucmakiola.agendula.data.tasks.room.TaskListRow +import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Clock +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant + +/** Rebuilt at least this often, so the expansion window and `distance_from_current` follow the clock. */ +private val MAX_AGE = 1.hours + +private val STARTUP_WAIT = 10.seconds + +private val NOTIFY_DELAY = 500.milliseconds + +/** + * Serves [AgendulaTaskProvider]: keeps a [SnapshotDatabase] of the Room store, + * rebuilt lazily after a write or a storage-mode switch, and tells observers of + * the provider's authority when either happens. + * + * Empty in [StorageMode.EXTERNAL]: the Room store is not the user's data then, + * and clients should read OpenTasks or tasks.org directly. + */ +@Singleton +class ProviderSnapshotSource internal constructor( + private val context: Context, + private val database: TasksDatabase, + private val resolver: ProviderResolver, + private val awaitStartup: suspend () -> Unit, + private val clock: Clock = Clock.System, +) { + + @Inject constructor( + @ApplicationContext context: Context, + database: TasksDatabase, + resolver: ProviderResolver, + startupGate: StartupGate, + ) : this(context, database, resolver, startupGate::awaitReady) + + private val started = AtomicBoolean(false) + private val snapshot by lazy { SnapshotDatabase() } + private val generation = AtomicLong(1) + private var builtGeneration = 0L + private var builtAt: Instant? = null + + private val handler = Handler(Looper.getMainLooper()) + private val notify = Runnable { + context.contentResolver.notifyChange(TasksContract.authorityUri(authority), null) + } + + val authority: String get() = "${context.packageName}.$AUTHORITY_SUFFIX" + + /** Starts watching for changes. Idempotent; touches the database, so not from the main thread. */ + fun start() { + if (!started.compareAndSet(false, true)) return + database.invalidationTracker.addObserver( + object : InvalidationTracker.Observer(OBSERVED_TABLES) { + override fun onInvalidated(tables: Set) { + if (resolver.mode() == StorageMode.OWN) changed() + } + }, + ) + resolver.onModeChanged(::changed) + } + + @Synchronized + fun query( + table: ProviderTable, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String?, + ): Cursor { + start() + // A query can be what starts the process; until the stored mode and the + // v0.3.x import land, autoMode() would answer for the wrong store. + runBlocking { withTimeoutOrNull(STARTUP_WAIT) { awaitStartup() } } + val now = clock.now() + val current = generation.get() + val expired = builtAt.let { it == null || now - it > MAX_AGE } + if (current != builtGeneration || expired) { + snapshot.load(build(now)) + builtGeneration = current + // Instance ids and distances move with the clock, so a timed rebuild is a change too. + if (expired && builtAt != null) notifyObservers() + builtAt = now + } + return snapshot.query(table, projection, selection, selectionArgs, sortOrder) + } + + private fun build(now: Instant): ProviderSnapshot { + if (resolver.mode() != StorageMode.OWN) return ProviderSnapshot.EMPTY + // Read consistently in one transaction; expand outside it, so the app's + // own writes do not wait on recurrence expansion. + val (lists, tasks, alarms) = database.runInTransaction, List, List>> { + Triple(database.taskLists().lists(), database.tasks().live(), database.alarms().all()) + } + return ProviderSnapshot.build(lists, tasks, alarms, context.getString(R.string.account_type), now) + } + + private fun changed() { + generation.incrementAndGet() + notifyObservers() + } + + /** Coalesced, so a sync committing many small transactions wakes each client once. */ + private fun notifyObservers() { + handler.removeCallbacks(notify) + handler.postDelayed(notify, NOTIFY_DELAY.inWholeMilliseconds) + } + + companion object { + const val AUTHORITY_SUFFIX = "tasks" + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/SnapshotDatabase.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/SnapshotDatabase.kt new file mode 100644 index 0000000..5e5d2ce --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/SnapshotDatabase.kt @@ -0,0 +1,88 @@ +package de.jeanlucmakiola.agendula.data.tasks.provider + +import android.content.ContentValues +import android.database.Cursor +import android.database.MatrixCursor +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteQueryBuilder +import android.os.Build +import androidx.core.database.sqlite.transaction + +/** + * An in-memory SQLite copy of a [ProviderSnapshot], so a client's own + * `selection`, `selectionArgs` and `sortOrder` run as SQL exactly as they would + * against OpenTasks. Holds only what the caller may already read. + * + * Not thread-safe; [ProviderSnapshotSource] serialises access. + */ +internal class SnapshotDatabase { + + private val db: SQLiteDatabase = SQLiteDatabase.create(null).apply { + ProviderTable.entries.forEach { table -> + execSQL( + "CREATE TABLE ${table.path} (" + + table.columns.joinToString { (name, type) -> "$name $type" } + ")", + ) + } + } + + fun load(snapshot: ProviderSnapshot) { + db.transaction { + ProviderTable.entries.forEach { table -> + delete(table.path, null, null) + snapshot.rows[table].orEmpty().forEach { insertOrThrow(table.path, null, it.toContentValues()) } + } + } + } + + /** + * Runs the query and copies the result out, so the cursor a client holds + * survives the next [load]. + */ + fun query( + table: ProviderTable, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String?, + ): Cursor { + val builder = SQLiteQueryBuilder().apply { + tables = table.path + isStrict = true + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + isStrictColumns = true + isStrictGrammar = true + } + projectionMap = table.columns.associate { (name, _) -> name to name } + } + builder.query(db, projection, selection, selectionArgs, null, null, sortOrder).use { source -> + val copy = MatrixCursor(source.columnNames, source.count) + while (source.moveToNext()) { + copy.addRow( + Array(source.columnCount) { column -> + when (source.getType(column)) { + Cursor.FIELD_TYPE_NULL -> null + Cursor.FIELD_TYPE_INTEGER -> source.getLong(column) + Cursor.FIELD_TYPE_FLOAT -> source.getDouble(column) + Cursor.FIELD_TYPE_BLOB -> source.getBlob(column) + else -> source.getString(column) + } + }, + ) + } + return copy + } + } + + private fun ProviderRow.toContentValues() = ContentValues(size).also { values -> + forEach { (key, value) -> + when (value) { + null -> values.putNull(key) + is Long -> values.put(key, value) + is Int -> values.put(key, value) + is String -> values.put(key, value) + else -> values.put(key, value.toString()) + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSource.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSource.kt index b759ea2..5db1346 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSource.kt @@ -31,7 +31,51 @@ private val SERIES_LINES = listOf("EXDATE", "RDATE", "RRULE", "RECURRENCE-ID", " /** Enough to count every occurrence before a split point without bounding a real series. */ internal const val SPLIT_COUNT_CEILING = 100_000 -private val OBSERVED_TABLES = arrayOf("tasks", "task_lists", "task_alarms", "accounts") +/** The window every read of the store expands a series over. */ +internal fun expansionWindow(now: Instant) = ExpansionWindow( + from = now - WINDOW_BACK, + until = now + WINDOW_FORWARD, + pivot = now, +) + +/** + * 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. + */ +internal fun occurrenceTimes(master: TaskEntity, anchor: Instant): Pair { + if (master.dtstart == null) return null to anchor + val length = master.due?.let { it - master.dtstart } + return anchor to length?.let { anchor + it } +} + +/** One occurrence of a series: its anchor, the override replacing it if any, and its resolved times. */ +internal class SeriesOccurrence( + val anchor: Instant, + val override: TaskEntity?, + val start: Instant?, + val due: Instant?, + val distance: Int?, +) + +/** + * Every occurrence of [master] inside the expansion window, with any + * `RECURRENCE-ID` override substituted for the occurrence it replaces, or + * `null` when [master] does not recur. + */ +internal fun seriesOccurrences(master: TaskEntity, overrides: List, now: Instant): List? { + val spec = master.recurrenceSpec() ?: return null + val anchors = RecurrenceExpander.expand(spec, expansionWindow(now)) + val distances = RecurrenceExpander.distancesFromCurrent(anchors, now) + val byAnchor = overrides.associateBy { it.recurrenceId } + return anchors.mapIndexed { index, anchor -> + val override = byAnchor[anchor] + val (start, due) = if (override != null) override.dtstart to override.due else occurrenceTimes(master, anchor) + SeriesOccurrence(anchor, override, start, due, distances.getOrNull(index)) + } +} + +internal val OBSERVED_TABLES = arrayOf("tasks", "task_lists", "task_alarms", "accounts") /** * [TasksDataSource] over Agendula's own Room store. @@ -123,54 +167,31 @@ class RoomTasksDataSource @Inject constructor( * [Task.occurrenceStart], so it keys and edits by task id exactly as before. */ private fun occurrencesOf(row: TaskRow, overrides: List, now: Instant): List { - val spec = row.task.recurrenceSpec() ?: return listOf(RoomTaskMapper.task(row)) - val window = ExpansionWindow( - from = now - WINDOW_BACK, - until = now + WINDOW_FORWARD, - pivot = now, - ) - val anchors = RecurrenceExpander.expand(spec, window) - if (anchors.isEmpty()) return emptyList() - - val distances = RecurrenceExpander.distancesFromCurrent(anchors, now) - val byAnchor = overrides.associateBy { it.recurrenceId } - - return anchors.mapIndexedNotNull { index, anchor -> - val override = byAnchor[anchor] + val occurrences = seriesOccurrences(row.task, overrides, now) ?: return listOf(RoomTaskMapper.task(row)) + return occurrences.map { occurrence -> + val override = occurrence.override if (override != null) { RoomTaskMapper.task( row = row.copy(task = override), - occurrenceStart = anchor, - start = override.dtstart, - due = override.due, - distanceFromCurrent = distances[index], + occurrenceStart = occurrence.anchor, + start = occurrence.start, + due = occurrence.due, + distanceFromCurrent = occurrence.distance, seriesId = row.task.id, recurrenceRule = row.task.rrule, ) } else { - val (start, due) = occurrenceTimes(row.task, anchor) RoomTaskMapper.task( row = row, - occurrenceStart = anchor, - start = start, - due = due, - distanceFromCurrent = distances[index], + occurrenceStart = occurrence.anchor, + start = occurrence.start, + due = occurrence.due, + distanceFromCurrent = occurrence.distance, ) } } } - /** - * One occurrence's resolved start and due. A timed series keeps each - * occurrence's duration; a due-anchored one has no start to offset from, so - * the anchor *is* the due date. - */ - private fun occurrenceTimes(master: TaskEntity, anchor: Instant): Pair { - if (master.dtstart == null) return null to anchor - val length = master.due?.let { it - master.dtstart } - return anchor to length?.let { anchor + it } - } - // --- writes --------------------------------------------------------------- override fun insertTask(form: TaskForm): Long { diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskDao.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskDao.kt index b7309e9..52d28f4 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskDao.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskDao.kt @@ -105,6 +105,10 @@ interface TaskDao { @Query("SELECT * FROM tasks WHERE list_id = :listId AND is_deleted = 0 AND master_id IS NULL") fun exportTasks(listId: Long): List + /** Every row but the tombstones, overrides included — what the published provider lays out. */ + @Query("SELECT * FROM tasks WHERE is_deleted = 0") + fun live(): List + @Query("SELECT * FROM tasks WHERE is_dirty = 1") fun dirty(): List diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 06da004..0f1c3cf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -854,4 +854,7 @@ Lists to show Week A seven-day strip of task counts + + read Agendula tasks + Allows the app to read your task lists and tasks stored in Agendula, including their reminders and tags. 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 new file mode 100644 index 0000000..67716f1 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderSnapshotTest.kt @@ -0,0 +1,326 @@ +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.Alarm +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Category +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.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.TaskListRow +import de.jeanlucmakiola.agendula.domain.TaskStatus +import org.junit.jupiter.api.Test +import java.time.ZoneId +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Instant + +class ProviderSnapshotTest { + + private val now = Instant.parse("2026-10-10T08:00:00Z") + private val local = TaskListRow(TaskListEntity(id = 1, name = "Home", color = 0xFF00FF00.toInt()), null) + private val synced = TaskListRow(TaskListEntity(id = 2, name = "Work", color = 0, accountId = 5), "Nextcloud") + + private fun build(tasks: List, alarms: List = emptyList()) = + ProviderSnapshot.build( + lists = listOf(local, synced), + tasks = tasks, + alarms = alarms, + accountType = "caldav-type", + now = now, + zone = ZoneId.of("Europe/Berlin"), + ) + + @Test + fun `lists report the local account for device-only lists and ours for synced ones`() { + val rows = build(emptyList()).rows.getValue(ProviderTable.LISTS) + + assertThat(rows.map { it[Lists.ACCOUNT_TYPE] }) + .containsExactly(TasksContract.LOCAL_ACCOUNT_TYPE, "caldav-type").inOrder() + assertThat(rows.map { it[Lists.ACCOUNT_NAME] }) + .containsExactly(TasksContract.LOCAL_ACCOUNT_NAME, "Nextcloud").inOrder() + } + + @Test + fun `a plain task is one task row and one instance`() { + val due = now + 1.days + val task = TaskEntity(id = 7, listId = 1, uid = "u7", title = "Milk", due = due, priority = 3) + + val snapshot = build(listOf(task)) + val row = snapshot.rows.getValue(ProviderTable.TASKS).single() + val instance = snapshot.rows.getValue(ProviderTable.INSTANCES).single() + + assertThat(row[Tasks.ID]).isEqualTo(7L) + assertThat(row[Tasks.PRIORITY]).isEqualTo(3) + assertThat(row[Tasks.LIST_NAME]).isEqualTo("Home") + assertThat(row[Tasks.IS_CLOSED]).isEqualTo(0) + assertThat(row[Tasks.IS_NEW]).isEqualTo(1) + assertThat(instance[Instances.TASK_ID]).isEqualTo(7L) + assertThat(instance[Instances.INSTANCE_DUE]).isEqualTo(due.toEpochMilliseconds()) + assertThat(instance[Instances.IS_RECURRING]).isEqualTo(0) + assertThat(instance[Instances.DISTANCE_FROM_CURRENT]).isEqualTo(0) + } + + @Test + fun `a completed task is closed`() { + val task = TaskEntity(id = 1, listId = 1, uid = "u", status = TaskStatus.COMPLETED, completedAt = now) + + val row = build(listOf(task)).rows.getValue(ProviderTable.TASKS).single() + + assertThat(row[Tasks.IS_CLOSED]).isEqualTo(1) + assertThat(row[Tasks.STATUS]).isEqualTo(TasksContract.STATUS_COMPLETED) + assertThat(row[Tasks.COMPLETED]).isEqualTo(now.toEpochMilliseconds()) + } + + @Test + fun `an override replaces the occurrence it names`() { + val start = Instant.parse("2026-10-09T09:00:00Z") + val master = TaskEntity( + id = 10, listId = 1, uid = "s", title = "Run", dtstart = start, due = start + 1.hours, + rrule = "FREQ=DAILY;COUNT=3", timezone = "UTC", + ) + val second = start + 1.days + val override = master.copy( + id = 11, rrule = null, recurrenceId = second, masterId = 10, title = "Run long", + dtstart = second + 2.hours, due = second + 4.hours, + ) + + val snapshot = build(listOf(master, override)) + val instances = snapshot.rows.getValue(ProviderTable.INSTANCES) + + assertThat(instances.map { it[Instances.TASK_ID] }).containsExactly(10L, 11L, 10L).inOrder() + assertThat(instances.map { it[Tasks.TITLE] }).containsExactly("Run", "Run long", "Run").inOrder() + assertThat(instances[1][Instances.INSTANCE_ORIGINAL_TIME]).isEqualTo(second.toEpochMilliseconds()) + assertThat(instances[1][Instances.INSTANCE_DURATION]).isEqualTo(2.hours.inWholeMilliseconds) + assertThat(instances.map { it[Tasks.ID] }).containsNoDuplicates() + + val overrideRow = snapshot.rows.getValue(ProviderTable.TASKS).single { it[Tasks.ID] == 11L } + assertThat(overrideRow[Tasks.ORIGINAL_INSTANCE_ID]).isEqualTo(10L) + assertThat(overrideRow[Tasks.ORIGINAL_INSTANCE_TIME]).isEqualTo(second.toEpochMilliseconds()) + } + + @Test + fun `an all-day date sorts at local midnight`() { + val day = Instant.parse("2026-10-12T00:00:00Z") + val task = TaskEntity(id = 1, listId = 1, uid = "u", due = day, isAllDay = true) + + val instance = build(listOf(task)).rows.getValue(ProviderTable.INSTANCES).single() + + assertThat(instance[Instances.INSTANCE_DUE]).isEqualTo(day.toEpochMilliseconds()) + assertThat(instance[Instances.INSTANCE_DUE_SORTING]).isEqualTo((day - 2.hours).toEpochMilliseconds()) + } + + @Test + fun `alarms and tags become properties`() { + val task = TaskEntity(id = 3, listId = 2, uid = "u", categories = listOf("errand", "home")) + val alarm = TaskAlarmEntity(id = 1, taskId = 3, minutesBefore = 15) + + val snapshot = build(listOf(task), listOf(alarm)) + val properties = snapshot.rows.getValue(ProviderTable.PROPERTIES) + + assertThat(snapshot.rows.getValue(ProviderTable.TASKS).single()[Tasks.HAS_ALARMS]).isEqualTo(1) + val alarmRow = properties.single { it[Properties.MIMETYPE] == Alarm.MIMETYPE } + assertThat(alarmRow[Alarm.MINUTES_BEFORE]).isEqualTo("15") + assertThat(alarmRow[Alarm.REFERENCE]).isEqualTo(Alarm.REFERENCE_DUE.toString()) + assertThat(properties.filter { it[Properties.MIMETYPE] == Category.MIMETYPE }.map { it[Category.NAME] }) + .containsExactly("errand", "home") + assertThat(properties.map { it[Properties.PROPERTY_ID] }).containsNoDuplicates() + } + + @Test + fun `every row carries exactly its table's columns`() { + val task = TaskEntity(id = 3, listId = 1, uid = "u", categories = listOf("a")) + val snapshot = build(listOf(task), listOf(TaskAlarmEntity(id = 1, taskId = 3, minutesBefore = 0))) + + ProviderTable.entries.forEach { table -> + val columns = table.columns.map { it.first }.toSet() + snapshot.rows.getValue(table).forEach { assertThat(columns).containsAtLeastElementsIn(it.keys) } + } + } + + @Test + fun `the empty snapshot has every table and no rows`() { + assertThat(ProviderSnapshot.EMPTY.rows.keys).containsExactlyElementsIn(ProviderTable.entries) + assertThat(ProviderSnapshot.EMPTY.rows.values.flatten()).isEmpty() + } + + @Test + fun `lists carry their name, colour and flags`() { + val hidden = TaskListRow(TaskListEntity(id = 3, name = "Old", color = 7, isVisible = false, isSynced = false), null) + + val row = ProviderSnapshot.build(listOf(hidden), emptyList(), emptyList(), "t", now) + .rows.getValue(ProviderTable.LISTS).single() + + assertThat(row[Lists.ID]).isEqualTo(3L) + assertThat(row[Lists.NAME]).isEqualTo("Old") + assertThat(row[Lists.COLOR]).isEqualTo(7) + assertThat(row[Lists.VISIBLE]).isEqualTo(0) + assertThat(row[Lists.SYNC_ENABLED]).isEqualTo(0) + } + + @Test + fun `a task reports its list's account`() { + val task = TaskEntity(id = 1, listId = 2, uid = "u") + + val row = build(listOf(task)).rows.getValue(ProviderTable.TASKS).single() + + assertThat(row[Tasks.LIST_NAME]).isEqualTo("Work") + assertThat(row[Tasks.ACCOUNT_NAME]).isEqualTo("Nextcloud") + assertThat(row[Tasks.ACCOUNT_TYPE]).isEqualTo("caldav-type") + } + + @Test + fun `a task whose list is gone is left out everywhere`() { + val orphan = TaskEntity(id = 4, listId = 99, uid = "u", categories = listOf("x")) + val alarm = TaskAlarmEntity(id = 1, taskId = 4, minutesBefore = 5) + + val snapshot = build(listOf(orphan), listOf(alarm)) + + assertThat(snapshot.rows.getValue(ProviderTable.TASKS)).isEmpty() + assertThat(snapshot.rows.getValue(ProviderTable.INSTANCES)).isEmpty() + assertThat(snapshot.rows.getValue(ProviderTable.PROPERTIES)).isEmpty() + } + + @Test + fun `task fields map to their OpenTasks columns`() { + val start = Instant.parse("2026-10-11T09:00:00Z") + val task = TaskEntity( + id = 5, listId = 1, uid = "uid-5", title = "T", description = "D", location = "L", url = "https://x", + color = 42, classification = 1, percentComplete = 50, dtstart = start, due = start + 1.hours, + timezone = "Europe/Berlin", parentId = 2, sortOrder = 3, createdAt = now, lastModified = now, + ) + + val row = build(listOf(task)).rows.getValue(ProviderTable.TASKS).single() + + assertThat(row[Tasks.UID]).isEqualTo("uid-5") + assertThat(row[Tasks.TITLE]).isEqualTo("T") + assertThat(row[Tasks.DESCRIPTION]).isEqualTo("D") + assertThat(row[Tasks.LOCATION]).isEqualTo("L") + assertThat(row[Tasks.URL]).isEqualTo("https://x") + assertThat(row[Tasks.TASK_COLOR]).isEqualTo(42) + assertThat(row[Tasks.CLASSIFICATION]).isEqualTo(1) + assertThat(row[Tasks.PERCENT_COMPLETE]).isEqualTo(50) + assertThat(row[Tasks.DTSTART]).isEqualTo(start.toEpochMilliseconds()) + assertThat(row[Tasks.DUE]).isEqualTo((start + 1.hours).toEpochMilliseconds()) + assertThat(row[Tasks.TZ]).isEqualTo("Europe/Berlin") + assertThat(row[Tasks.IS_ALLDAY]).isEqualTo(0) + assertThat(row[Tasks.PARENT_ID]).isEqualTo(2L) + assertThat(row[Tasks.SORTING]).isEqualTo(3) + assertThat(row[Tasks.CREATED]).isEqualTo(now.toEpochMilliseconds()) + assertThat(row[Tasks.LAST_MODIFIED]).isEqualTo(now.toEpochMilliseconds()) + assertThat(row[Tasks.DELETED]).isEqualTo(0) + assertThat(row[Tasks.HAS_ALARMS]).isEqualTo(0) + assertThat(row[Tasks.ORIGINAL_INSTANCE_ID]).isNull() + assertThat(row[Tasks.ORIGINAL_INSTANCE_ALLDAY]).isNull() + } + + @Test + fun `a task with progress is no longer new`() { + val task = TaskEntity(id = 1, listId = 1, uid = "u", percentComplete = 10) + + assertThat(build(listOf(task)).rows.getValue(ProviderTable.TASKS).single()[Tasks.IS_NEW]).isEqualTo(0) + } + + @Test + fun `a cancelled task is closed`() { + val task = TaskEntity(id = 1, listId = 1, uid = "u", status = TaskStatus.CANCELLED) + + val row = build(listOf(task)).rows.getValue(ProviderTable.TASKS).single() + + assertThat(row[Tasks.IS_CLOSED]).isEqualTo(1) + assertThat(row[Tasks.STATUS]).isEqualTo(TasksContract.STATUS_CANCELLED) + } + + @Test + fun `a series expands into one instance per occurrence`() { + val start = Instant.parse("2026-10-09T09:00:00Z") + val master = TaskEntity( + id = 10, listId = 1, uid = "s", dtstart = start, due = start + 1.hours, + rrule = "FREQ=DAILY;COUNT=3", timezone = "UTC", + ) + + val snapshot = build(listOf(master)) + val instances = snapshot.rows.getValue(ProviderTable.INSTANCES) + + assertThat(snapshot.rows.getValue(ProviderTable.TASKS)).hasSize(1) + assertThat(instances.map { it[Instances.TASK_ID] }).containsExactly(10L, 10L, 10L) + assertThat(instances.map { it[Instances.IS_RECURRING] }).containsExactly(1, 1, 1) + assertThat(instances.map { it[Instances.INSTANCE_START] }).containsExactlyElementsIn( + (0..2).map { (start + it.days).toEpochMilliseconds() }, + ).inOrder() + assertThat(instances.map { it[Instances.INSTANCE_ORIGINAL_TIME] }) + .isEqualTo(instances.map { it[Instances.INSTANCE_START] }) + assertThat(instances.map { it[Instances.INSTANCE_DURATION] }).containsExactly( + 1.hours.inWholeMilliseconds, 1.hours.inWholeMilliseconds, 1.hours.inWholeMilliseconds, + ) + // 10-09 is past, 10-10 09:00 is the next one at or after 08:00. + assertThat(instances.map { it[Instances.DISTANCE_FROM_CURRENT] }).containsExactly(-1, 0, 1).inOrder() + } + + @Test + fun `a due-anchored series has no start`() { + val due = Instant.parse("2026-10-12T17:00:00Z") + val master = TaskEntity(id = 10, listId = 1, uid = "s", due = due, rrule = "FREQ=WEEKLY;COUNT=2", timezone = "UTC") + + val instances = build(listOf(master)).rows.getValue(ProviderTable.INSTANCES) + + assertThat(instances.map { it[Instances.INSTANCE_START] }).containsExactly(null, null) + assertThat(instances.map { it[Instances.INSTANCE_DUE] }).containsExactly( + due.toEpochMilliseconds(), (due + 7.days).toEpochMilliseconds(), + ).inOrder() + assertThat(instances.map { it[Instances.INSTANCE_DURATION] }).containsExactly(null, null) + } + + @Test + fun `instance ids are unique across series and plain tasks`() { + val start = Instant.parse("2026-10-09T09:00:00Z") + val a = TaskEntity(id = 1, listId = 1, uid = "a", dtstart = start, rrule = "FREQ=DAILY;COUNT=4", timezone = "UTC") + val b = TaskEntity(id = 2, listId = 1, uid = "b", dtstart = start, rrule = "FREQ=DAILY;COUNT=4", timezone = "UTC") + val plain = TaskEntity(id = 3, listId = 2, uid = "c") + + val ids = build(listOf(a, b, plain)).rows.getValue(ProviderTable.INSTANCES).map { it[Tasks.ID] } + + assertThat(ids).hasSize(9) + assertThat(ids).containsNoDuplicates() + } + + @Test + fun `an override is flagged all-day like the occurrence it replaces`() { + val day = Instant.parse("2026-10-09T00:00:00Z") + val master = TaskEntity(id = 1, listId = 1, uid = "s", due = day, isAllDay = true, rrule = "FREQ=DAILY;COUNT=2") + val override = master.copy(id = 2, rrule = null, masterId = 1, recurrenceId = day + 1.days) + + val row = build(listOf(master, override)).rows.getValue(ProviderTable.TASKS).single { it[Tasks.ID] == 2L } + + assertThat(row[Tasks.ORIGINAL_INSTANCE_ALLDAY]).isEqualTo(1) + } + + @Test + fun `a timed date sorts as itself`() { + val due = Instant.parse("2026-10-12T15:30:00Z") + val task = TaskEntity(id = 1, listId = 1, uid = "u", due = due, dtstart = due - 1.hours) + + val instance = build(listOf(task)).rows.getValue(ProviderTable.INSTANCES).single() + + assertThat(instance[Instances.INSTANCE_DUE_SORTING]).isEqualTo(due.toEpochMilliseconds()) + assertThat(instance[Instances.INSTANCE_START_SORTING]).isEqualTo((due - 1.hours).toEpochMilliseconds()) + } + + @Test + fun `an alarm before the start says so`() { + val task = TaskEntity(id = 3, listId = 1, uid = "u") + val alarm = TaskAlarmEntity(id = 1, taskId = 3, minutesBefore = 60, reference = AlarmReference.START, message = "Go") + + val row = build(listOf(task), listOf(alarm)).rows.getValue(ProviderTable.PROPERTIES).single() + + assertThat(row[Properties.TASK_ID]).isEqualTo(3L) + assertThat(row[Alarm.REFERENCE]).isEqualTo(Alarm.REFERENCE_START.toString()) + assertThat(row[Alarm.MESSAGE]).isEqualTo("Go") + assertThat(row[Alarm.ALARM_TYPE]).isEqualTo(Alarm.TYPE_MESSAGE.toString()) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderTableTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderTableTest.kt new file mode 100644 index 0000000..f74153d --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderTableTest.kt @@ -0,0 +1,70 @@ +package de.jeanlucmakiola.agendula.data.tasks.provider + +import com.google.common.truth.Truth.assertThat +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 org.junit.jupiter.api.Test + +class ProviderTableTest { + + @Test + fun `paths follow the OpenTasks contract`() { + assertThat(ProviderTable.entries.map { it.path }) + .containsExactly("tasklists", "tasks", "instances", "properties").inOrder() + } + + @Test + fun `each table keys on its OpenTasks id column`() { + assertThat(ProviderTable.LISTS.idColumn).isEqualTo(Lists.ID) + assertThat(ProviderTable.TASKS.idColumn).isEqualTo(Tasks.ID) + assertThat(ProviderTable.INSTANCES.idColumn).isEqualTo(Tasks.ID) + assertThat(ProviderTable.PROPERTIES.idColumn).isEqualTo(Properties.PROPERTY_ID) + } + + @Test + fun `no table declares a column twice`() { + ProviderTable.entries.forEach { table -> + assertThat(table.columns.map { it.first }).containsNoDuplicates() + } + } + + @Test + fun `instances carry every task column`() { + val tasks = ProviderTable.TASKS.columns.map { it.first } + val instances = ProviderTable.INSTANCES.columns.map { it.first } + + assertThat(instances).containsAtLeastElementsIn(tasks) + assertThat(instances).containsAtLeast(Instances.TASK_ID, Instances.INSTANCE_ORIGINAL_TIME) + } + + @Test + fun `unsupported OpenTasks columns are declared so a projection naming them succeeds`() { + assertThat(ProviderTable.TASKS.columns.map { it.first }).containsAtLeast("_dirty", "_sync_id", "sync1", "pinned") + } + + @Test + fun `a table URI keeps the caller's selection untouched`() { + assertThat(ProviderTable.TASKS.selectionFor(null, "title = ?")).isEqualTo("title = ?") + assertThat(ProviderTable.TASKS.selectionFor(null, null)).isNull() + } + + @Test + fun `a row URI narrows to its id`() { + assertThat(ProviderTable.TASKS.selectionFor(4, null)).isEqualTo("_id = 4") + assertThat(ProviderTable.PROPERTIES.selectionFor(9, null)).isEqualTo("property_id = 9") + } + + @Test + fun `a row URI parenthesises the caller's selection so an OR cannot widen it`() { + assertThat(ProviderTable.TASKS.selectionFor(4, "a = 1 OR b = 2")).isEqualTo("_id = 4 AND (a = 1 OR b = 2)") + } + + @Test + fun `MIME types are OpenTasks' own`() { + assertThat(ProviderTable.TASKS.mimeType(single = false)).isEqualTo("vnd.android.cursor.dir/org.dmfs.tasks.tasks") + assertThat(ProviderTable.LISTS.mimeType(single = true)) + .isEqualTo("vnd.android.cursor.item/org.dmfs.tasks.tasklists") + } +} diff --git a/docs/CONTENT_PROVIDER.md b/docs/CONTENT_PROVIDER.md new file mode 100644 index 0000000..cd78bed --- /dev/null +++ b/docs/CONTENT_PROVIDER.md @@ -0,0 +1,54 @@ +# Reading 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 +[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. + +| | | +|---|---| +| 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 | +| 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`. + +| Build | Package | +|---|---| +| Release | `de.jeanlucmakiola.agendula` | +| Release, offline | `de.jeanlucmakiola.agendula.offline` | +| Debug | `de.jeanlucmakiola.agendula.debug` | +| Debug, offline | `de.jeanlucmakiola.agendula.offline.debug` | +| Release test | `de.jeanlucmakiola.agendula.releasetest` | +| Release test, offline | `de.jeanlucmakiola.agendula.offline.releasetest` | + +## Tables + +| URI | Rows | +|---|---| +| `content:///tasklists` | one per list | +| `content:///tasks` | one per task; a modified occurrence of a recurring task is its own row with `original_instance_id` / `original_instance_time` set | +| `content:///instances` | one per occurrence: recurring tasks are expanded up to one year back and two years ahead, and modified occurrences replace the ones they override | +| `content:///properties` | reminders (`vnd.android.cursor.item/alarm`) and tags (`vnd.android.cursor.item/category`) | + +Each table also accepts `/`. Column names, value encodings (epoch millis, +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. + +## 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`. diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 60a91df..a9e5162 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -1,7 +1,7 @@ --- title: Privacy Policy — Agendula description: What Agendula does with your data. No servers, no account, no analytics — your tasks stay on your device unless you add a CalDAV server yourself. -updated: 2026-09-21 +updated: 2026-10-15 --- -**Last updated:** 21 September 2026 +**Last updated:** 15 October 2026 Applies to the Android app **Agendula** (package `de.jeanlucmakiola.agendula`), all versions and all distribution channels. @@ -53,8 +53,9 @@ All of this is verifiable in the ## 3. Where your tasks live — your choice - **On your device (the default)** — your task lists, tasks and reminders are - kept in Agendula's own database inside the app's private storage. Nothing is - published to other apps, and uninstalling the app removes it. + 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). - **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 @@ -209,8 +210,13 @@ source: used for scheduled background work (WorkManager); on older Android versions it needs them to run an expedited sync. -Agendula publishes no content provider of its own and declares no permissions -that other apps could request. +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. ## 10. Distribution channels