Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
baa5366d43 | ||
|
|
d123c00def | ||
|
|
522d5ca35e |
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
+58
-13
@@ -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<Context>()
|
||||
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. */
|
||||
|
||||
+811
@@ -0,0 +1,811 @@
|
||||
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<Context>()
|
||||
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<String, Any?>): 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<Triple<Long, Long, Int>> =
|
||||
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)
|
||||
}
|
||||
|
||||
// --- second review regressions -----------------------------------------------
|
||||
|
||||
@Test
|
||||
fun tickingOffAModifiedOccurrenceAsReadLeavesTheSeriesAlone() {
|
||||
val series = dailySeries("Standup")
|
||||
// Reopening forks an override, which then gets its own title as the app's "this occurrence" edit would.
|
||||
store().setCompletedInstance(series, today, false)
|
||||
val override = db.tasks().overrides(series).single()
|
||||
db.tasks().update(override.copy(title = "Standup (moved room)"))
|
||||
val row = readRow(ProviderTable.INSTANCES, instanceId(today))
|
||||
|
||||
writer.update(ProviderTable.INSTANCES, row + (Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||
|
||||
assertThat(task(series)!!.title).isEqualTo("Standup")
|
||||
assertThat(task(override.id)!!.title).isEqualTo("Standup (moved room)")
|
||||
assertThat(task(override.id)!!.status).isEqualTo(TaskStatus.COMPLETED)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anInstanceWrittenBackWithOnlyProgressChangedIsCompleted() {
|
||||
val series = dailySeries()
|
||||
val row = readRow(ProviderTable.INSTANCES, instanceId(today))
|
||||
|
||||
writer.update(ProviderTable.INSTANCES, row + (Tasks.PERCENT_COMPLETE to 100), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||
|
||||
assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.COMPLETED)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aBadZoneOnAnInstanceReportsTheZone() {
|
||||
dailySeries()
|
||||
val instance = instanceId(today)
|
||||
|
||||
val error = assertThrows(IllegalArgumentException::class.java) {
|
||||
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.TZ to "Europe/Berln"), byId(ProviderTable.INSTANCES, instance), null)
|
||||
}
|
||||
|
||||
assertThat(error).hasMessageThat().contains("Unknown time zone")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anInProcessSeriesCanBeWrittenBackAsRead() {
|
||||
val series = dailySeries()
|
||||
db.tasks().update(task(series)!!.copy(status = TaskStatus.IN_PROCESS, percentComplete = 50))
|
||||
val row = readRow(ProviderTable.INSTANCES, instanceId(today))
|
||||
|
||||
writer.update(ProviderTable.INSTANCES, row + (Tasks.TITLE to "Renamed"), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||
|
||||
assertThat(task(series)!!.title).isEqualTo("Renamed")
|
||||
assertThat(task(series)!!.status).isEqualTo(TaskStatus.IN_PROCESS)
|
||||
assertThat(db.tasks().overrides(series)).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aFloatingSeriesCanBeWrittenBackAsRead() {
|
||||
val series = db.tasks().insert(
|
||||
TaskEntity(listId = home, uid = "f", title = "Floating", due = today, rrule = "FREQ=DAILY;COUNT=3", timezone = null),
|
||||
)
|
||||
val row = readRow(ProviderTable.INSTANCES, instanceId(today))
|
||||
|
||||
writer.update(ProviderTable.INSTANCES, row + (Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||
writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "Still floating"), byId(ProviderTable.TASKS, series), null)
|
||||
|
||||
assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.COMPLETED)
|
||||
assertThat(task(series)!!.timezone).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aParentLoopDeletesInsteadOfRecursingForever() {
|
||||
val a = insert(Tasks.TITLE to "a")
|
||||
val b = insert(Tasks.TITLE to "b", Tasks.PARENT_ID to a)
|
||||
db.tasks().update(task(a)!!.copy(parentId = b))
|
||||
|
||||
writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, a), null)
|
||||
|
||||
assertThat(task(a)).isNull()
|
||||
assertThat(task(b)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aBulkMoveWorksWhicheverOrderTheRowsComeIn() {
|
||||
val work = synced()
|
||||
// The subtask gets the lower id, so it comes first unless the writer reorders.
|
||||
val child = insert(Tasks.TITLE to "child")
|
||||
val parent = insert(Tasks.TITLE to "parent")
|
||||
db.tasks().update(task(child)!!.copy(parentId = parent))
|
||||
|
||||
val count = writer.update(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to work), "${Tasks.LIST_ID} = ?", arrayOf(home.toString()))
|
||||
|
||||
assertThat(count).isEqualTo(2)
|
||||
assertThat(task(child)!!.listId).isEqualTo(work)
|
||||
assertThat(task(parent)!!.listId).isEqualTo(work)
|
||||
assertThat(task(child)!!.parentId).isEqualTo(parent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aRowSelectedWithAnArgumentIsWritten() {
|
||||
val a = insert(Tasks.TITLE to "a")
|
||||
val b = insert(Tasks.TITLE to "b")
|
||||
|
||||
assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), "${Tasks.ID}=?", arrayOf(b.toString()))).isEqualTo(1)
|
||||
assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), "${Tasks.ID} = ?", arrayOf("987654"))).isEqualTo(0)
|
||||
|
||||
assertThat(task(a)!!.title).isEqualTo("a")
|
||||
assertThat(task(b)!!.title).isEqualTo("x")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun garbageExceptionDatesAreRejected() {
|
||||
val series = dailySeries()
|
||||
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
writer.update(ProviderTable.TASKS, mapOf(Tasks.EXDATE to "tomorrow"), byId(ProviderTable.TASKS, series), null)
|
||||
}
|
||||
assertThat(task(series)!!.exdate).isNull()
|
||||
}
|
||||
|
||||
private fun store() = RoomTasksDataSource(db)
|
||||
|
||||
/** Every column of one published row, as a client that read it would write it back. */
|
||||
private fun readRow(table: ProviderTable, id: Long): Map<String, Any?> {
|
||||
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
|
||||
}
|
||||
+43
@@ -539,4 +539,47 @@ 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)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletingASubtreeEndsOnAParentLoop() {
|
||||
val a = source.insertTask(form("a"))
|
||||
val b = source.insertTask(form("b").copy(parentId = a))
|
||||
val c = source.insertTask(form("c").copy(parentId = b))
|
||||
db.tasks().update(db.tasks().entity(a)!!.copy(parentId = c))
|
||||
|
||||
source.deleteWithSubtasks(a)
|
||||
|
||||
assertThat(listOf(a, b, c).mapNotNull { db.tasks().entity(it) }).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletingFollowingFromTheFirstOccurrenceTakesTheWholeSubtree() {
|
||||
val start = now
|
||||
val series = db.tasks().insert(
|
||||
TaskEntity(listId = listId, uid = "s", title = "s", dtstart = start, rrule = "FREQ=DAILY;COUNT=3"),
|
||||
)
|
||||
val child = source.insertTask(form("child").copy(parentId = series))
|
||||
val grandchild = source.insertTask(form("grandchild").copy(parentId = child))
|
||||
|
||||
source.deleteFollowing(series, start)
|
||||
|
||||
assertThat(listOf(series, child, grandchild).mapNotNull { db.tasks().entity(it) }).isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,14 +15,19 @@
|
||||
<uses-permission android:name="org.tasks.permission.READ_TASKS" />
|
||||
<uses-permission android:name="org.tasks.permission.WRITE_TASKS" />
|
||||
|
||||
<!-- What another app requests to read the own store through
|
||||
AgendulaTaskProvider. Dangerous, like OpenTasks' own: the user grants it
|
||||
per app. Suffixed per build, so debug and release can coexist. -->
|
||||
<!-- What another app requests to read or write the own store through
|
||||
AgendulaTaskProvider. Dangerous, like OpenTasks' own: the user grants
|
||||
them per app. Suffixed per build, so debug and release can coexist. -->
|
||||
<permission
|
||||
android:name="${applicationId}.permission.READ_TASKS"
|
||||
android:description="@string/permission_read_tasks_description"
|
||||
android:label="@string/permission_read_tasks_label"
|
||||
android:protectionLevel="dangerous" />
|
||||
<permission
|
||||
android:name="${applicationId}.permission.WRITE_TASKS"
|
||||
android:description="@string/permission_write_tasks_description"
|
||||
android:label="@string/permission_write_tasks_label"
|
||||
android:protectionLevel="dangerous" />
|
||||
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
@@ -241,13 +246,14 @@
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- The own store, read-only, under the OpenTasks contract. Empty while
|
||||
External mode is active. -->
|
||||
<!-- The own store under the OpenTasks contract. Empty, and refusing
|
||||
writes, while External mode is active. -->
|
||||
<provider
|
||||
android:name=".data.tasks.provider.AgendulaTaskProvider"
|
||||
android:authorities="${applicationId}.tasks"
|
||||
android:exported="true"
|
||||
android:permission="${applicationId}.permission.READ_TASKS"
|
||||
android:readPermission="${applicationId}.permission.READ_TASKS"
|
||||
android:writePermission="${applicationId}.permission.WRITE_TASKS"
|
||||
tools:ignore="SystemPermissionTypo" />
|
||||
|
||||
<!-- WorkManager's on-demand initialisation. Removing the default
|
||||
|
||||
@@ -85,6 +85,27 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
override fun subtasks(parentTaskId: Long): List<Task> =
|
||||
queryInstances("${Tasks.PARENT_ID} = ?", arrayOf(parentTaskId.toString()))
|
||||
|
||||
/**
|
||||
* Read from the `tasks` table, so a recurring subtask with no occurrence in
|
||||
* the window still counts. All columns and a lenient `_deleted`, as in
|
||||
* [exportTasks], since the table's shape varies across providers.
|
||||
*/
|
||||
override fun subtaskIds(parentTaskId: Long): List<Long> = resolver.query(
|
||||
TasksContract.tasksUri(authority()),
|
||||
null,
|
||||
"${Tasks.PARENT_ID} = ? AND (${Tasks.DELETED} IS NULL OR ${Tasks.DELETED} = 0)",
|
||||
arrayOf(parentTaskId.toString()),
|
||||
null,
|
||||
)?.use { c ->
|
||||
val r = CursorColumnReader(c)
|
||||
buildList {
|
||||
while (c.moveToNext()) {
|
||||
if (r.getLong(Tasks.ORIGINAL_INSTANCE_ID) != null) continue
|
||||
r.getLong(Tasks.ID)?.takeIf { it != parentTaskId }?.let(::add)
|
||||
}
|
||||
}
|
||||
} ?: emptyList()
|
||||
|
||||
private fun queryInstances(selection: String?, args: Array<String>?): List<Task> {
|
||||
val uri = TasksContract.instancesUri(authority())
|
||||
// projection = null (all columns): the instances view differs across
|
||||
@@ -201,7 +222,7 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
|
||||
override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) {
|
||||
val spec = seriesSpec(seriesId) ?: return
|
||||
if (spec.rrule == null || occurrenceStart <= spec.anchor) return deleteTask(seriesId)
|
||||
if (spec.rrule == null || occurrenceStart <= spec.anchor) return deleteWithSubtasks(seriesId)
|
||||
endSeriesBefore(seriesId, spec, occurrenceStart)
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@ class ModeRoutingTasksDataSource(
|
||||
override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) =
|
||||
active().deleteFollowing(seriesId, occurrenceStart)
|
||||
override fun subtasks(parentTaskId: Long): List<Task> = active().subtasks(parentTaskId)
|
||||
override fun subtaskIds(parentTaskId: Long): List<Long> = active().subtaskIds(parentTaskId)
|
||||
override fun deleteWithSubtasks(taskId: Long) = active().deleteWithSubtasks(taskId)
|
||||
override fun insertTask(form: TaskForm): Long = active().insertTask(form)
|
||||
override fun updateTask(taskId: Long, form: TaskForm) = active().updateTask(taskId, form)
|
||||
|
||||
|
||||
@@ -46,6 +46,29 @@ interface TasksDataSource {
|
||||
fun task(taskId: Long, occurrenceStart: Instant?): Task? = task(taskId)
|
||||
fun subtasks(parentTaskId: Long): List<Task>
|
||||
|
||||
/**
|
||||
* The task rows directly under [parentTaskId]: one id per subtask, its series
|
||||
* master when it recurs, whether or not any occurrence falls in the window
|
||||
* [subtasks] expands.
|
||||
*/
|
||||
fun subtaskIds(parentTaskId: Long): List<Long> =
|
||||
subtasks(parentTaskId).map { it.seriesId ?: it.taskId }.distinct().filter { it != parentTaskId }
|
||||
|
||||
/**
|
||||
* Delete [taskId] and its subtasks, all the way down; left behind they would
|
||||
* hang off a tombstone. Each task once, so a parent loop an import brought in
|
||||
* ends instead of recursing forever.
|
||||
*/
|
||||
fun deleteWithSubtasks(taskId: Long) {
|
||||
val seen = HashSet<Long>()
|
||||
fun delete(id: Long) {
|
||||
if (!seen.add(id)) return
|
||||
subtaskIds(id).forEach(::delete)
|
||||
deleteTask(id)
|
||||
}
|
||||
delete(taskId)
|
||||
}
|
||||
|
||||
fun insertTask(form: TaskForm): Long
|
||||
fun updateTask(taskId: Long, form: TaskForm)
|
||||
|
||||
|
||||
@@ -248,7 +248,7 @@ class TasksRepositoryImpl @Inject constructor(
|
||||
val current = dataSource.task(taskId, occurrenceStart)
|
||||
val seriesId = current?.seriesId
|
||||
val occurrence = current?.occurrenceStart
|
||||
if (seriesId == null || occurrence == null) return@withContext deleteWithSubtasks(taskId)
|
||||
if (seriesId == null || occurrence == null) return@withContext dataSource.deleteWithSubtasks(taskId)
|
||||
when (scope) {
|
||||
RecurringScope.ThisOccurrence ->
|
||||
// An override row deletes itself into an EXDATE; a generated
|
||||
@@ -256,18 +256,10 @@ class TasksRepositoryImpl @Inject constructor(
|
||||
if (current.taskId != seriesId) dataSource.deleteTask(current.taskId)
|
||||
else dataSource.deleteOccurrence(seriesId, occurrence)
|
||||
RecurringScope.ThisAndFollowing -> dataSource.deleteFollowing(seriesId, occurrence)
|
||||
RecurringScope.AllOccurrences -> deleteWithSubtasks(seriesId)
|
||||
RecurringScope.AllOccurrences -> dataSource.deleteWithSubtasks(seriesId)
|
||||
}
|
||||
}
|
||||
|
||||
/** A parent goes with its subtasks; left behind they would hang off a tombstone. */
|
||||
private fun deleteWithSubtasks(taskId: Long) {
|
||||
dataSource.subtasks(taskId).map { it.taskId }.distinct()
|
||||
.filter { it != taskId }
|
||||
.forEach { dataSource.deleteTask(it) }
|
||||
dataSource.deleteTask(taskId)
|
||||
}
|
||||
|
||||
override suspend fun createLocalList(name: String, color: Int): Long =
|
||||
withContext(io) { dataSource.createLocalList(name, color) }
|
||||
|
||||
|
||||
+33
-12
@@ -1,6 +1,9 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.provider
|
||||
|
||||
import android.content.ContentProvider
|
||||
import android.content.ContentProviderOperation
|
||||
import android.content.ContentProviderResult
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.UriMatcher
|
||||
import android.database.Cursor
|
||||
@@ -11,11 +14,12 @@ 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 `<applicationId>.tasks`,
|
||||
* so an app that already reads OpenTasks or tasks.org reads Agendula by
|
||||
* swapping the authority. Guarded by `<applicationId>.permission.READ_TASKS`
|
||||
* in the manifest. See `docs/CONTENT_PROVIDER.md`.
|
||||
* Agendula's own store, published under the OpenTasks contract (`tasklists`,
|
||||
* `tasks`, `instances`, `properties`) at `<applicationId>.tasks`, so an app that
|
||||
* already works with OpenTasks or tasks.org works with Agendula by swapping the
|
||||
* authority. Reads are guarded by `<applicationId>.permission.READ_TASKS`, writes
|
||||
* by `<applicationId>.permission.WRITE_TASKS`; [ProviderWriter] applies them. See
|
||||
* `docs/CONTENT_PROVIDER.md`.
|
||||
*/
|
||||
class AgendulaTaskProvider : ContentProvider() {
|
||||
|
||||
@@ -23,11 +27,14 @@ class AgendulaTaskProvider : ContentProvider() {
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface ProviderEntryPoint {
|
||||
fun snapshotSource(): ProviderSnapshotSource
|
||||
fun writer(): ProviderWriter
|
||||
}
|
||||
|
||||
private val source: ProviderSnapshotSource by lazy {
|
||||
EntryPointAccessors.fromApplication(requireNotNull(context), ProviderEntryPoint::class.java).snapshotSource()
|
||||
private val entryPoint by lazy {
|
||||
EntryPointAccessors.fromApplication(requireNotNull(context), ProviderEntryPoint::class.java)
|
||||
}
|
||||
private val source: ProviderSnapshotSource by lazy { entryPoint.snapshotSource() }
|
||||
private val writer: ProviderWriter by lazy { entryPoint.writer() }
|
||||
|
||||
private val matcher by lazy {
|
||||
UriMatcher(UriMatcher.NO_MATCH).apply {
|
||||
@@ -58,12 +65,25 @@ class AgendulaTaskProvider : ContentProvider() {
|
||||
return table.mimeType(single = id != null)
|
||||
}
|
||||
|
||||
override fun insert(uri: Uri, values: ContentValues?): Uri = readOnly()
|
||||
override fun insert(uri: Uri, values: ContentValues?): Uri {
|
||||
val (table, id) = match(uri)
|
||||
require(id == null) { "Insert into ${table.path}, not into one of its rows" }
|
||||
return ContentUris.withAppendedId(uri, writer.insert(table, values.toMap()))
|
||||
}
|
||||
|
||||
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int =
|
||||
readOnly()
|
||||
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int {
|
||||
val (table, id) = match(uri)
|
||||
return writer.update(table, values.toMap(), table.selectionFor(id, selection), selectionArgs)
|
||||
}
|
||||
|
||||
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = readOnly()
|
||||
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
|
||||
val (table, id) = match(uri)
|
||||
return writer.delete(table, table.selectionFor(id, selection), selectionArgs)
|
||||
}
|
||||
|
||||
/** All or nothing, as against OpenTasks, and observers hear of it once. */
|
||||
override fun applyBatch(operations: ArrayList<ContentProviderOperation>): Array<ContentProviderResult> =
|
||||
writer.batch { super.applyBatch(operations) }
|
||||
|
||||
private fun match(uri: Uri): Pair<ProviderTable, Long?> {
|
||||
val code = matcher.match(uri)
|
||||
@@ -74,5 +94,6 @@ class AgendulaTaskProvider : ContentProvider() {
|
||||
return table to id
|
||||
}
|
||||
|
||||
private fun readOnly(): Nothing = throw UnsupportedOperationException("Agendula's task provider is read-only")
|
||||
private fun ContentValues?.toMap(): ProviderValues =
|
||||
this?.keySet()?.associateWith { get(it) }.orEmpty()
|
||||
}
|
||||
|
||||
+63
-5
@@ -19,6 +19,63 @@ import kotlin.time.Instant
|
||||
|
||||
typealias ProviderRow = Map<String, Any?>
|
||||
|
||||
/**
|
||||
* Ids that stay put for as long as what they name exists, so a client can read
|
||||
* an id and write to it later, or several in one batch. The one exception is
|
||||
* two tags on one task whose spellings hash alike (about one in 2³¹): removing
|
||||
* the one that sorts first moves the other onto its id.
|
||||
*
|
||||
* An instance is its series and its original time; a plain task's one
|
||||
* instance has none. An alarm property is the alarm row; a tag property is its
|
||||
* task and its spelling. Alarms take even ids and tags odd ones, so the two
|
||||
* never meet.
|
||||
*/
|
||||
object ProviderIds {
|
||||
|
||||
fun instance(seriesId: Long, originalTime: Instant?): Long =
|
||||
(seriesId shl 32) or ((originalTime?.epochSeconds ?: 0L) and 0xFFFFFFFFL)
|
||||
|
||||
fun instanceSeries(instanceId: Long): Long = instanceId ushr 32
|
||||
|
||||
fun instanceSeconds(instanceId: Long): Long = instanceId and 0xFFFFFFFFL
|
||||
|
||||
fun alarm(alarmId: Long): Long = alarmId shl 1
|
||||
|
||||
fun isAlarm(propertyId: Long): Boolean = propertyId and 1L == 0L
|
||||
|
||||
fun alarmRow(propertyId: Long): Long = propertyId ushr 1
|
||||
|
||||
/** The property id of each of [taskId]'s [categories], by spelling. */
|
||||
fun categories(taskId: Long, categories: List<String>): Map<String, Long> =
|
||||
categories(taskId, categories, ::tagHash)
|
||||
|
||||
internal fun categories(taskId: Long, categories: List<String>, hash: (String) -> Long): Map<String, Long> {
|
||||
val used = HashSet<Long>()
|
||||
// Two spellings on one task with one hash: the one that sorts later takes
|
||||
// the next free slot, wrapping inside the tag bits so it never reaches the
|
||||
// task's. Sorting keeps that independent of the tags' order.
|
||||
val ids = categories.sorted().associateWith { name ->
|
||||
var bits = hash(name) and TAG_BITS
|
||||
while (!used.add(bits)) bits = (bits + 1) and TAG_BITS
|
||||
(((taskId shl 31) or bits) shl 1) or 1L
|
||||
}
|
||||
return categories.associateWith(ids::getValue)
|
||||
}
|
||||
|
||||
fun categoryTask(propertyId: Long): Long = propertyId ushr 32
|
||||
|
||||
private const val TAG_BITS = 0x7FFFFFFFL
|
||||
|
||||
/** 64-bit FNV-1a over the spelling's UTF-8, folded to 31 bits. */
|
||||
private fun tagHash(name: String): Long {
|
||||
var hash = -0x340d631b7bdddcdbL
|
||||
name.encodeToByteArray().forEach { byte ->
|
||||
hash = (hash xor (byte.toLong() and 0xFF)) * 0x100000001b3L
|
||||
}
|
||||
return (hash xor (hash ushr 31) xor (hash ushr 62)) and TAG_BITS
|
||||
}
|
||||
}
|
||||
|
||||
/** The Room store laid out as OpenTasks' tables, ready to be queried. */
|
||||
class ProviderSnapshot(val rows: Map<ProviderTable, List<ProviderRow>>) {
|
||||
|
||||
@@ -46,11 +103,10 @@ class ProviderSnapshot(val rows: Map<ProviderTable, List<ProviderRow>>) {
|
||||
}.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)
|
||||
columns + instanceColumns(ProviderIds.instance(task.id, occurrence.originalTime), occurrence, zone)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +231,7 @@ class ProviderSnapshot(val rows: Map<ProviderTable, List<ProviderRow>>) {
|
||||
val live = tasks.mapTo(HashSet()) { it.id }
|
||||
val alarmRows = alarms.filter { it.taskId in live }.map {
|
||||
mapOf(
|
||||
Properties.PROPERTY_ID to ProviderIds.alarm(it.id),
|
||||
Properties.TASK_ID to it.taskId,
|
||||
Properties.MIMETYPE to Alarm.MIMETYPE,
|
||||
Alarm.MINUTES_BEFORE to it.minutesBefore.toString(),
|
||||
@@ -187,15 +244,16 @@ class ProviderSnapshot(val rows: Map<ProviderTable, List<ProviderRow>>) {
|
||||
)
|
||||
}
|
||||
val categoryRows = tasks.flatMap { task ->
|
||||
task.categories.map {
|
||||
ProviderIds.categories(task.id, task.categories).map { (name, id) ->
|
||||
mapOf(
|
||||
Properties.PROPERTY_ID to id,
|
||||
Properties.TASK_ID to task.id,
|
||||
Properties.MIMETYPE to Category.MIMETYPE,
|
||||
Category.NAME to it,
|
||||
Category.NAME to name,
|
||||
)
|
||||
}
|
||||
}
|
||||
return (alarmRows + categoryRows).mapIndexed { index, row -> row + (Properties.PROPERTY_ID to index + 1L) }
|
||||
return alarmRows + categoryRows
|
||||
}
|
||||
|
||||
private fun accountName(row: TaskListRow): String =
|
||||
|
||||
+49
-4
@@ -23,6 +23,7 @@ import java.util.concurrent.atomic.AtomicLong
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
@@ -50,6 +51,7 @@ class ProviderSnapshotSource internal constructor(
|
||||
private val resolver: ProviderResolver,
|
||||
private val awaitStartup: suspend () -> Unit,
|
||||
private val clock: Clock = Clock.System,
|
||||
private val startupWait: Duration = STARTUP_WAIT,
|
||||
) {
|
||||
|
||||
@Inject constructor(
|
||||
@@ -93,10 +95,54 @@ class ProviderSnapshotSource internal constructor(
|
||||
selectionArgs: Array<out String>?,
|
||||
sortOrder: String?,
|
||||
): Cursor {
|
||||
refresh()
|
||||
return snapshot.query(table, projection, selection, selectionArgs, sortOrder)
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs [block] holding the snapshot's lock, once startup has settled which
|
||||
* store is the user's. A write refuses to run without that, where a read
|
||||
* falls back to answering from whichever store autoMode() picks.
|
||||
*/
|
||||
fun <T> locked(block: () -> T): T {
|
||||
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() } }
|
||||
checkNotNull(runBlocking { withTimeoutOrNull(startupWait) { awaitStartup() } }) {
|
||||
"Agendula is still starting; try again"
|
||||
}
|
||||
return synchronized(this, block)
|
||||
}
|
||||
|
||||
/** The [columns] of every row of [table] that [selection] matches, as of now. */
|
||||
@Synchronized
|
||||
fun rows(table: ProviderTable, columns: List<String>, selection: String?, selectionArgs: Array<out String>?): List<List<Any?>> =
|
||||
query(table, columns.toTypedArray(), selection, selectionArgs, null).use { cursor ->
|
||||
buildList {
|
||||
while (cursor.moveToNext()) {
|
||||
add(
|
||||
List(cursor.columnCount) { column ->
|
||||
when (cursor.getType(column)) {
|
||||
Cursor.FIELD_TYPE_NULL -> null
|
||||
Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(column)
|
||||
else -> cursor.getString(column)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the snapshot stale at once. A provider write calls this so the next
|
||||
* operation of the same batch sees it, before Room's own invalidation lands.
|
||||
*/
|
||||
fun invalidate() {
|
||||
generation.incrementAndGet()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun refresh() {
|
||||
start()
|
||||
runBlocking { withTimeoutOrNull(startupWait) { awaitStartup() } }
|
||||
val now = clock.now()
|
||||
val current = generation.get()
|
||||
val expired = builtAt.let { it == null || now - it > MAX_AGE }
|
||||
@@ -107,7 +153,6 @@ class ProviderSnapshotSource internal constructor(
|
||||
if (expired && builtAt != null) notifyObservers()
|
||||
builtAt = now
|
||||
}
|
||||
return snapshot.query(table, projection, selection, selectionArgs, sortOrder)
|
||||
}
|
||||
|
||||
private fun build(now: Instant): ProviderSnapshot {
|
||||
|
||||
+304
@@ -0,0 +1,304 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.provider
|
||||
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.forAllDay
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import de.jeanlucmakiola.agendula.domain.statusFromInt
|
||||
import org.dmfs.rfc5545.recur.RecurrenceRule
|
||||
import java.time.ZoneId
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** A client's `ContentValues`, as plain Kotlin values. */
|
||||
typealias ProviderValues = Map<String, Any?>
|
||||
|
||||
/** The `tasks` columns a client may write. Everything else the table publishes is ignored on a write. */
|
||||
val WRITABLE_TASK_COLUMNS: Set<String> = setOf(
|
||||
Tasks.LIST_ID, Tasks.UID, Tasks.TITLE, Tasks.DESCRIPTION, Tasks.LOCATION, Tasks.URL, Tasks.PRIORITY,
|
||||
Tasks.CLASSIFICATION, Tasks.STATUS, Tasks.PERCENT_COMPLETE, Tasks.COMPLETED, Tasks.TASK_COLOR, Tasks.DTSTART,
|
||||
Tasks.DUE, Tasks.DURATION, Tasks.IS_ALLDAY, Tasks.TZ, Tasks.RRULE, Tasks.RDATE, Tasks.EXDATE, Tasks.PARENT_ID,
|
||||
Tasks.SORTING,
|
||||
)
|
||||
|
||||
/** The columns that say whether a task, or one occurrence of it, is done. */
|
||||
val COMPLETION_COLUMNS: Set<String> = setOf(Tasks.STATUS, Tasks.PERCENT_COMPLETE, Tasks.COMPLETED)
|
||||
|
||||
/** The columns that place a task in time; one occurrence of a series cannot be moved through the provider. */
|
||||
val TIME_COLUMNS: Set<String> = setOf(
|
||||
Tasks.DTSTART, Tasks.DUE, Tasks.DURATION, Tasks.IS_ALLDAY, Tasks.TZ, Tasks.RRULE, Tasks.RDATE, Tasks.EXDATE,
|
||||
)
|
||||
|
||||
/** What a write to one occurrence of a series does to it. */
|
||||
enum class OccurrenceChange { COMPLETE, CANCEL, REOPEN }
|
||||
|
||||
private val ICAL_DATE = Regex("""\d{8}(T\d{6}Z?)?""")
|
||||
|
||||
private val ICAL_DURATION = Regex("""[+-]?P(\d+W|(\d+D)?(T(\d+H)?(\d+M)?(\d+S)?)?)""")
|
||||
|
||||
/**
|
||||
* Turns a client's OpenTasks `ContentValues` into [TaskEntity] changes. Pure, so
|
||||
* the rules are testable on the JVM. A bad value throws [IllegalArgumentException],
|
||||
* which reaches the client as is.
|
||||
*
|
||||
* Only [WRITABLE_TASK_COLUMNS] change anything. The rest of what the provider
|
||||
* publishes is accepted and ignored, so a client can write back a row it read;
|
||||
* the fields Agendula keeps for CalDAV (`href`, `etag`, `sequence`, unknown
|
||||
* properties) are not published and so cannot be written at all.
|
||||
*/
|
||||
object ProviderTaskWriter {
|
||||
|
||||
/**
|
||||
* [values] narrowed to the columns in [writable]. A column [table] does not
|
||||
* have at all is a client bug and throws.
|
||||
*/
|
||||
fun writable(table: ProviderTable, values: ProviderValues, writable: Set<String>): ProviderValues {
|
||||
val known = table.columns.mapTo(HashSet()) { it.first }
|
||||
val unknown = values.keys - known
|
||||
require(unknown.isEmpty()) { "Unknown column(s) for ${table.path}: ${unknown.sorted().joinToString()}" }
|
||||
return values.filterKeys { it in writable }
|
||||
}
|
||||
|
||||
/** A new task from [values], which must name its list. */
|
||||
fun newTask(values: ProviderValues, uid: String, now: Instant, tzId: String): TaskEntity {
|
||||
val v = Values(values)
|
||||
val listId = requireNotNull(v.long(Tasks.LIST_ID)) { "A new task needs a ${Tasks.LIST_ID}" }
|
||||
val base = TaskEntity(listId = listId, uid = v.string(Tasks.UID)?.trim()?.ifEmpty { null } ?: uid, createdAt = now)
|
||||
// As in OpenTasks, a new task inserted without a zone is floating.
|
||||
val zoned = if (Tasks.TZ in v) values else values + (Tasks.TZ to null)
|
||||
return applied(base, zoned - Tasks.LIST_ID - Tasks.UID, now, tzId)
|
||||
}
|
||||
|
||||
/**
|
||||
* [current] with [values] applied. Identity and sync columns are left alone,
|
||||
* and [current] itself comes back when nothing changed, so a write that
|
||||
* changes nothing owes the server nothing.
|
||||
*/
|
||||
fun patch(current: TaskEntity, values: ProviderValues, now: Instant, tzId: String): TaskEntity {
|
||||
val edited = applied(current, values, now, tzId)
|
||||
return if (edited.copy(lastModified = current.lastModified, isDirty = current.isDirty) == current) current else edited
|
||||
}
|
||||
|
||||
/**
|
||||
* The columns in [values] that would change [current] on their own. Writing
|
||||
* back what a row published changes nothing, so a client that read a row can
|
||||
* send all of it with the one value it meant to change.
|
||||
*/
|
||||
fun changedColumns(current: TaskEntity, values: ProviderValues, tzId: String): Set<String> {
|
||||
val stamp = current.lastModified ?: Instant.DISTANT_PAST
|
||||
return values.keys.filterTo(HashSet()) { key ->
|
||||
val edited = runCatching { applied(current, mapOf(key to values[key]), stamp, tzId) }.getOrNull()
|
||||
edited == null || edited.copy(lastModified = current.lastModified, isDirty = current.isDirty) != current
|
||||
}
|
||||
}
|
||||
|
||||
private fun applied(current: TaskEntity, values: ProviderValues, now: Instant, tzId: String): TaskEntity {
|
||||
val v = Values(values)
|
||||
val isOverride = current.masterId != null
|
||||
if (Tasks.UID in v) require(v.string(Tasks.UID)?.trim() == current.uid) { "A task's ${Tasks.UID} cannot change" }
|
||||
if (isOverride) {
|
||||
// NULL is what the row publishes, so writing it back is fine.
|
||||
require(listOf(Tasks.RRULE, Tasks.RDATE, Tasks.EXDATE).none { it in v && v.text(it) != null }) {
|
||||
"A modified occurrence carries no recurrence rule of its own"
|
||||
}
|
||||
if (Tasks.LIST_ID in v) require(v.long(Tasks.LIST_ID) == current.listId) { "A modified occurrence stays in its series' list" }
|
||||
if (Tasks.PARENT_ID in v) require(v.long(Tasks.PARENT_ID) == current.parentId) { "A modified occurrence keeps its series' parent" }
|
||||
}
|
||||
|
||||
val listId = if (Tasks.LIST_ID in v) requireNotNull(v.long(Tasks.LIST_ID)) { "${Tasks.LIST_ID} cannot be null" } else current.listId
|
||||
val parentId = if (Tasks.PARENT_ID in v) v.long(Tasks.PARENT_ID)?.takeIf { it > 0 } else current.parentId
|
||||
require(parentId == null || parentId != current.id || current.id == 0L) { "A task cannot be its own parent" }
|
||||
|
||||
val allDay = if (Tasks.IS_ALLDAY in v) v.boolean(Tasks.IS_ALLDAY) else current.isAllDay
|
||||
val dtstart = (if (Tasks.DTSTART in v) v.instant(Tasks.DTSTART) else current.dtstart)?.forAllDay(allDay)
|
||||
val due = (if (Tasks.DUE in v) v.instant(Tasks.DUE) else current.due)?.forAllDay(allDay)
|
||||
val duration = when {
|
||||
Tasks.DURATION in v -> v.string(Tasks.DURATION)?.trim()?.ifEmpty { null }
|
||||
?.also { require(ICAL_DURATION.matches(it)) { "Not an iCalendar duration: $it" } }
|
||||
Tasks.DUE in v && due != null -> null
|
||||
else -> current.duration
|
||||
}
|
||||
require(due == null || duration == null) { "${Tasks.DUE} and ${Tasks.DURATION} are mutually exclusive" }
|
||||
require(dtstart == null || due == null || due >= dtstart) { "${Tasks.DUE} is before ${Tasks.DTSTART}" }
|
||||
val timezone = when {
|
||||
allDay -> null
|
||||
Tasks.TZ in v -> v.string(Tasks.TZ)?.also { zone ->
|
||||
require(runCatching { ZoneId.of(zone) }.isSuccess) { "Unknown time zone: $zone" }
|
||||
}
|
||||
dtstart == null && due == null -> null
|
||||
// A timed task without a zone is floating (RFC 5545 §3.3.5) and stays so.
|
||||
current.dtstart != null || current.due != null -> if (current.isAllDay) tzId else current.timezone
|
||||
else -> current.timezone ?: tzId
|
||||
}
|
||||
|
||||
val rrule = if (Tasks.RRULE in v) normalizedRule(v.string(Tasks.RRULE)) else current.rrule
|
||||
val ruleDropped = Tasks.RRULE in v && rrule == null && current.rrule != null
|
||||
val rdate = if (Tasks.RDATE in v) dateList(Tasks.RDATE, v, current.rdate) else current.rdate.takeUnless { ruleDropped }
|
||||
val exdate = if (Tasks.EXDATE in v) dateList(Tasks.EXDATE, v, current.exdate) else current.exdate.takeUnless { ruleDropped }
|
||||
require(rrule == null || dtstart != null || due != null) { "A recurring task needs a ${Tasks.DTSTART} or ${Tasks.DUE}" }
|
||||
|
||||
val (status, percent, completedAt) = completion(current, v, now)
|
||||
|
||||
return current.copy(
|
||||
listId = listId,
|
||||
title = if (Tasks.TITLE in v) v.string(Tasks.TITLE)?.trim() else current.title,
|
||||
description = if (Tasks.DESCRIPTION in v) v.text(Tasks.DESCRIPTION) else current.description,
|
||||
location = if (Tasks.LOCATION in v) v.text(Tasks.LOCATION) else current.location,
|
||||
url = if (Tasks.URL in v) v.text(Tasks.URL) else current.url,
|
||||
priority = if (Tasks.PRIORITY in v) v.int(Tasks.PRIORITY, 0..9) ?: TasksContract.PRIORITY_NONE else current.priority,
|
||||
classification = if (Tasks.CLASSIFICATION in v) v.int(Tasks.CLASSIFICATION, 0..2) else current.classification,
|
||||
color = if (Tasks.TASK_COLOR in v) v.int(Tasks.TASK_COLOR) else current.color,
|
||||
sortOrder = if (Tasks.SORTING in v) v.int(Tasks.SORTING) ?: 0 else current.sortOrder,
|
||||
status = status,
|
||||
percentComplete = percent,
|
||||
completedAt = completedAt,
|
||||
dtstart = dtstart,
|
||||
due = due,
|
||||
duration = duration,
|
||||
isAllDay = allDay,
|
||||
timezone = timezone,
|
||||
rrule = rrule,
|
||||
rdate = rdate,
|
||||
exdate = exdate,
|
||||
parentId = parentId,
|
||||
lastModified = now,
|
||||
isDirty = true,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What [values] do to one occurrence of a series, or `null` when they say
|
||||
* nothing about its completion. An occurrence is done, cancelled or open;
|
||||
* partial progress belongs to a task, not to one of its occurrences.
|
||||
*/
|
||||
fun occurrenceChange(values: ProviderValues): OccurrenceChange? {
|
||||
val v = Values(values.filterKeys { it in COMPLETION_COLUMNS })
|
||||
return when {
|
||||
Tasks.STATUS in v -> when (v.int(Tasks.STATUS, 0..3)) {
|
||||
TasksContract.STATUS_COMPLETED -> OccurrenceChange.COMPLETE
|
||||
TasksContract.STATUS_CANCELLED -> OccurrenceChange.CANCEL
|
||||
TasksContract.STATUS_NEEDS_ACTION, null -> OccurrenceChange.REOPEN
|
||||
else -> throw IllegalArgumentException("An occurrence is completed, cancelled or open, not in process")
|
||||
}
|
||||
Tasks.PERCENT_COMPLETE in v -> when (v.int(Tasks.PERCENT_COMPLETE, 0..100)) {
|
||||
100 -> OccurrenceChange.COMPLETE
|
||||
0, null -> OccurrenceChange.REOPEN
|
||||
else -> throw IllegalArgumentException("An occurrence is completed or open; partial progress belongs to its task")
|
||||
}
|
||||
Tasks.COMPLETED in v -> if (v.instant(Tasks.COMPLETED) != null) OccurrenceChange.COMPLETE else OccurrenceChange.REOPEN
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Status, progress and completion time after a write. They move together the
|
||||
* way OpenTasks keeps them: a status implies its progress and time, progress
|
||||
* implies a status, and a completion time alone completes the task. When a
|
||||
* row is written back, the column that changed decides, not the ones it
|
||||
* carries unchanged.
|
||||
*/
|
||||
private fun completion(current: TaskEntity, values: Values, now: Instant): Triple<TaskStatus, Int?, Instant?> {
|
||||
val v = Values(values.map.filterKeys { it !in COMPLETION_COLUMNS || it in completionChanged(current, values) })
|
||||
val percent = if (Tasks.PERCENT_COMPLETE in v) v.int(Tasks.PERCENT_COMPLETE, 0..100) else current.percentComplete
|
||||
val completed = if (Tasks.COMPLETED in v) v.instant(Tasks.COMPLETED) else current.completedAt
|
||||
return when {
|
||||
Tasks.STATUS in v -> when (val status = statusFromInt(v.int(Tasks.STATUS, 0..3))) {
|
||||
TaskStatus.COMPLETED -> Triple(status, if (Tasks.PERCENT_COMPLETE in v) percent else 100, completed ?: now)
|
||||
TaskStatus.NEEDS_ACTION ->
|
||||
Triple(status, if (Tasks.PERCENT_COMPLETE in v) percent?.takeIf { it < 100 } else null, null)
|
||||
else -> Triple(status, percent, null)
|
||||
}
|
||||
Tasks.PERCENT_COMPLETE in v -> when {
|
||||
percent == null -> Triple(current.status, null, current.completedAt)
|
||||
percent >= 100 -> Triple(TaskStatus.COMPLETED, percent, completed ?: now)
|
||||
percent > 0 -> Triple(TaskStatus.IN_PROCESS, percent, null)
|
||||
else -> Triple(TaskStatus.NEEDS_ACTION, percent, null)
|
||||
}
|
||||
Tasks.COMPLETED in v -> when {
|
||||
completed != null -> Triple(TaskStatus.COMPLETED, 100, completed)
|
||||
// Clearing the time reopens a completed task; an open one has none to clear.
|
||||
current.status == TaskStatus.COMPLETED -> Triple(TaskStatus.NEEDS_ACTION, null, null)
|
||||
else -> Triple(current.status, current.percentComplete, null)
|
||||
}
|
||||
else -> Triple(current.status, current.percentComplete, current.completedAt)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The completion columns in [v] that differ from [current]. All of them when
|
||||
* none does, so a write that only repeats the row still settles the rest.
|
||||
*/
|
||||
private fun completionChanged(current: TaskEntity, v: Values): Set<String> {
|
||||
val present = COMPLETION_COLUMNS.filterTo(HashSet()) { it in v }
|
||||
val changed = present.filterTo(HashSet()) { column ->
|
||||
when (column) {
|
||||
Tasks.STATUS -> statusFromInt(v.int(Tasks.STATUS, 0..3)) != current.status
|
||||
Tasks.PERCENT_COMPLETE -> v.int(Tasks.PERCENT_COMPLETE, 0..100) != current.percentComplete
|
||||
else -> v.instant(Tasks.COMPLETED) != current.completedAt
|
||||
}
|
||||
}
|
||||
return changed.ifEmpty { present }
|
||||
}
|
||||
|
||||
/**
|
||||
* An `RDATE`/`EXDATE` value: DATE or DATE-TIME values, comma-separated, of
|
||||
* one kind. [stored] written back as is stays, whatever shape it synced in.
|
||||
*/
|
||||
private fun dateList(column: String, v: Values, stored: String?): String? {
|
||||
val raw = v.string(column)?.trim()?.ifEmpty { null } ?: return null
|
||||
if (raw == stored?.trim()) return stored
|
||||
val values = raw.split(',').map(String::trim)
|
||||
require(values.all { ICAL_DATE.matches(it) }) { "$column takes iCalendar dates or date-times: $raw" }
|
||||
// lib-recur reads the whole list or none of it, so it has to be one kind.
|
||||
val kinds = values.map { if (it.length == 8) "date" else if (it.endsWith('Z')) "utc" else "local" }
|
||||
require(kinds.distinct().size == 1) { "$column mixes value kinds: $raw" }
|
||||
return values.joinToString(",")
|
||||
}
|
||||
|
||||
private fun normalizedRule(raw: String?): String? {
|
||||
val rule = raw?.trim()?.removePrefix("RRULE:")?.ifEmpty { null } ?: return null
|
||||
require(runCatching { RecurrenceRule(rule) }.isSuccess) { "Not a recurrence rule: $rule" }
|
||||
return rule
|
||||
}
|
||||
}
|
||||
|
||||
/** Lenient reads over [values]: a client may hand a number as a string or a flag as an integer. */
|
||||
internal class Values(private val values: ProviderValues) {
|
||||
|
||||
val keys: Set<String> get() = values.keys
|
||||
|
||||
val map: ProviderValues get() = values
|
||||
|
||||
operator fun contains(key: String) = key in values
|
||||
|
||||
fun string(key: String): String? = values[key]?.toString()
|
||||
|
||||
/** A free-text column: trimmed, blank stored as nothing. */
|
||||
fun text(key: String): String? = string(key)?.trim()?.ifEmpty { null }
|
||||
|
||||
fun long(key: String): Long? = when (val value = values[key]) {
|
||||
null -> null
|
||||
is Number -> value.toLong()
|
||||
is Boolean -> if (value) 1L else 0L
|
||||
is String -> requireNotNull(value.trim().toLongOrNull()) { "$key is not a number: $value" }
|
||||
else -> throw IllegalArgumentException("$key is not a number: $value")
|
||||
}
|
||||
|
||||
fun int(key: String, range: IntRange? = null): Int? = long(key)?.let { value ->
|
||||
require(value in Int.MIN_VALUE..Int.MAX_VALUE) { "$key is out of range: $value" }
|
||||
value.toInt().also { require(range == null || it in range) { "$key must be in $range: $it" } }
|
||||
}
|
||||
|
||||
fun boolean(key: String): Boolean = when (val value = values[key]) {
|
||||
null -> false
|
||||
is Boolean -> value
|
||||
is String -> when (value.trim().lowercase()) {
|
||||
"1", "true" -> true
|
||||
"0", "false", "" -> false
|
||||
else -> throw IllegalArgumentException("$key is not a flag: $value")
|
||||
}
|
||||
else -> long(key) != 0L
|
||||
}
|
||||
|
||||
fun instant(key: String): Instant? = long(key)?.let(Instant::fromEpochMilliseconds)
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.provider
|
||||
|
||||
import android.database.sqlite.SQLiteConstraintException
|
||||
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
||||
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TaskWriteFailedException
|
||||
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.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.TasksDatabase
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.seriesOccurrences
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import de.jeanlucmakiola.agendula.domain.normalizeTags
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.ZoneId
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** One row by id, `<id column> = N` or `<id column> = ?`. */
|
||||
private val SINGLE_ROW = Regex("""\s*(\w+)\s*=\s*(\d+|\?)\s*""")
|
||||
|
||||
/**
|
||||
* Applies another app's writes to the own store for [AgendulaTaskProvider].
|
||||
*
|
||||
* A write's selection runs against the published snapshot, so it matches the
|
||||
* rows the client can read, and each matched row is then written through
|
||||
* [RoomTasksDataSource] under the rules the app's own edits follow. `tasklists`
|
||||
* stays read-only. On `instances`, completion columns tick off that one
|
||||
* occurrence of a series; other content columns edit the series, and a changed
|
||||
* time column is refused, since one occurrence cannot be moved this way.
|
||||
*
|
||||
* Each call runs in one transaction, and a batch in one more around them.
|
||||
*/
|
||||
@Singleton
|
||||
class ProviderWriter internal constructor(
|
||||
private val database: TasksDatabase,
|
||||
private val store: RoomTasksDataSource,
|
||||
private val source: ProviderSnapshotSource,
|
||||
private val resolver: ProviderResolver,
|
||||
private val afterWrite: () -> Unit,
|
||||
private val clock: Clock = Clock.System,
|
||||
) {
|
||||
|
||||
@Inject constructor(
|
||||
database: TasksDatabase,
|
||||
store: RoomTasksDataSource,
|
||||
source: ProviderSnapshotSource,
|
||||
resolver: ProviderResolver,
|
||||
scheduler: ReminderScheduler,
|
||||
@ApplicationScope scope: CoroutineScope,
|
||||
) : this(database, store, source, resolver, { scope.launch { runCatching { scheduler.sync() } } })
|
||||
|
||||
private var batchDepth = 0
|
||||
private var batchWrote = false
|
||||
|
||||
/** Inserts one row and returns its published id. */
|
||||
fun insert(table: ProviderTable, values: ProviderValues): Long = write {
|
||||
when (table) {
|
||||
ProviderTable.TASKS -> insertTask(values)
|
||||
ProviderTable.PROPERTIES -> insertProperty(values)
|
||||
else -> throw UnsupportedOperationException("${table.path} is read-only")
|
||||
}
|
||||
}
|
||||
|
||||
/** Updates every row [selection] matches and returns how many changed. */
|
||||
fun update(table: ProviderTable, values: ProviderValues, selection: String?, args: Array<out String>?): Int = write {
|
||||
when (table) {
|
||||
ProviderTable.TASKS -> {
|
||||
val writable = ProviderTaskWriter.writable(table, values, WRITABLE_TASK_COLUMNS)
|
||||
val matched = ids(table, selection, args)
|
||||
(if (Tasks.LIST_ID in writable) parentsFirst(matched) else matched).count { updateTask(it, writable) }
|
||||
}
|
||||
ProviderTable.INSTANCES -> {
|
||||
val writable = ProviderTaskWriter.writable(table, values, WRITABLE_TASK_COLUMNS)
|
||||
instances(selection, args).count { updateInstance(it, writable) }
|
||||
}
|
||||
ProviderTable.PROPERTIES -> ids(table, selection, args).count { updateProperty(it, values) }
|
||||
else -> throw UnsupportedOperationException("${table.path} is read-only")
|
||||
}
|
||||
}
|
||||
|
||||
/** Deletes every row [selection] matches and returns how many went. */
|
||||
fun delete(table: ProviderTable, selection: String?, args: Array<out String>?): Int = write {
|
||||
when (table) {
|
||||
ProviderTable.TASKS -> ids(table, selection, args).count { deleteTask(it) }
|
||||
ProviderTable.INSTANCES -> instances(selection, args).count { deleteInstance(it) }
|
||||
ProviderTable.PROPERTIES -> ids(table, selection, args).count { deleteProperty(it) }
|
||||
else -> throw UnsupportedOperationException("${table.path} is read-only")
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs a client's batch in one transaction, re-arming reminders once at the end. */
|
||||
fun <T> batch(block: () -> T): T = source.locked {
|
||||
batchDepth++
|
||||
try {
|
||||
database.runInTransaction<T> { block() }
|
||||
} finally {
|
||||
// A snapshot built mid-batch may hold rows a failure rolled back.
|
||||
source.invalidate()
|
||||
batchDepth--
|
||||
if (batchDepth == 0 && batchWrote) {
|
||||
batchWrote = false
|
||||
afterWrite()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> write(block: () -> T): T = source.locked {
|
||||
check(resolver.mode() == StorageMode.OWN) {
|
||||
"Agendula is working on top of an external tasks provider; write to that one instead"
|
||||
}
|
||||
val result = try {
|
||||
database.runInTransaction<T> { block() }
|
||||
} catch (e: TaskWriteFailedException) {
|
||||
throw IllegalArgumentException(e.message, e)
|
||||
} catch (e: SQLiteConstraintException) {
|
||||
throw IllegalArgumentException(e.message, e)
|
||||
} finally {
|
||||
source.invalidate()
|
||||
}
|
||||
if (batchDepth == 0) afterWrite() else batchWrote = true
|
||||
result
|
||||
}
|
||||
|
||||
// --- tasks ------------------------------------------------------------------
|
||||
|
||||
private fun insertTask(values: ProviderValues): Long {
|
||||
require(values[Tasks.ORIGINAL_INSTANCE_ID] == null && values[Tasks.ORIGINAL_INSTANCE_TIME] == null) {
|
||||
"Modify an occurrence through ${Instances.PATH}, not by inserting one"
|
||||
}
|
||||
val writable = ProviderTaskWriter.writable(ProviderTable.TASKS, values, WRITABLE_TASK_COLUMNS)
|
||||
val task = ProviderTaskWriter.newTask(writable, UUID.randomUUID().toString(), clock.now(), zone())
|
||||
requireWritableList(task.listId)
|
||||
// The unique index cannot catch this: SQLite treats the masters' NULL recurrence_id as distinct.
|
||||
require(database.tasks().byUid(task.listId, task.uid) == null) { "List ${task.listId} already has a task ${task.uid}" }
|
||||
requireParent(task)
|
||||
return store.insertEntity(task)
|
||||
}
|
||||
|
||||
private fun updateTask(taskId: Long, values: ProviderValues): Boolean {
|
||||
val current = database.tasks().entity(taskId)?.takeUnless { it.isDeleted } ?: return false
|
||||
requireWritableList(current.listId)
|
||||
val edited = ProviderTaskWriter.patch(current, values, clock.now(), zone())
|
||||
if (edited == current) return true
|
||||
val moved = edited.listId != current.listId
|
||||
if (moved) {
|
||||
requireWritableList(edited.listId)
|
||||
requireFreeUid(edited.listId, current.uid)
|
||||
}
|
||||
if (edited.parentId != current.parentId || moved) requireParent(edited)
|
||||
store.replaceEntity(current, edited)
|
||||
// A subtree moves as one: a subtask has to be in its parent's list.
|
||||
if (moved) store.subtaskIds(taskId).forEach { moveSubtree(it, edited.listId, hashSetOf(taskId)) }
|
||||
return true
|
||||
}
|
||||
|
||||
private fun moveSubtree(taskId: Long, listId: Long, seen: MutableSet<Long> = HashSet()) {
|
||||
if (!seen.add(taskId)) return
|
||||
val current = database.tasks().entity(taskId)?.takeUnless { it.isDeleted } ?: return
|
||||
if (current.listId != listId) {
|
||||
requireFreeUid(listId, current.uid)
|
||||
store.replaceEntity(current, current.copy(listId = listId, lastModified = clock.now(), isDirty = true))
|
||||
}
|
||||
store.subtaskIds(taskId).forEach { moveSubtree(it, listId, seen) }
|
||||
}
|
||||
|
||||
/**
|
||||
* [ids] with every parent before its subtasks, so a bulk move carries a
|
||||
* subtree along before reaching its subtasks, whatever order they came in.
|
||||
*/
|
||||
private fun parentsFirst(ids: List<Long>): List<Long> {
|
||||
if (ids.size < 2) return ids
|
||||
fun depth(id: Long): Int {
|
||||
val seen = HashSet<Long>()
|
||||
var parent = database.tasks().entity(id)?.parentId
|
||||
while (parent != null && seen.add(parent)) parent = database.tasks().entity(parent)?.parentId
|
||||
return seen.size
|
||||
}
|
||||
val depths = ids.associateWith(::depth)
|
||||
return ids.sortedBy(depths::getValue)
|
||||
}
|
||||
|
||||
private fun deleteTask(taskId: Long): Boolean {
|
||||
val current = database.tasks().entity(taskId)?.takeUnless { it.isDeleted } ?: return false
|
||||
requireWritableList(current.listId)
|
||||
// An override deletes itself into an EXDATE on its master.
|
||||
if (current.masterId != null) store.deleteTask(taskId) else store.deleteWithSubtasks(taskId)
|
||||
return true
|
||||
}
|
||||
|
||||
// --- instances --------------------------------------------------------------
|
||||
|
||||
/** One occurrence: the row it reads from, its series, and when it was due to happen. */
|
||||
private class InstanceRow(val taskId: Long, val seriesId: Long, val originalTime: Instant?, val recurring: Boolean)
|
||||
|
||||
private fun instances(selection: String?, args: Array<out String>?): List<InstanceRow> {
|
||||
singleId(ProviderTable.INSTANCES, selection, args)?.let { return listOfNotNull(instanceRow(it)) }
|
||||
return source.rows(
|
||||
ProviderTable.INSTANCES,
|
||||
listOf(Tasks.ID, Instances.TASK_ID, Instances.INSTANCE_ORIGINAL_TIME, Instances.IS_RECURRING),
|
||||
selection,
|
||||
args,
|
||||
).map { (id, taskId, original, recurring) ->
|
||||
InstanceRow(
|
||||
taskId = taskId as Long,
|
||||
seriesId = ProviderIds.instanceSeries(id as Long),
|
||||
originalTime = (original as Long?)?.let(Instant::fromEpochMilliseconds),
|
||||
recurring = recurring == 1L,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** [instanceId] read straight from the store, without rebuilding the snapshot. */
|
||||
private fun instanceRow(instanceId: Long): InstanceRow? {
|
||||
val master = database.tasks().entity(ProviderIds.instanceSeries(instanceId))
|
||||
?.takeIf { !it.isDeleted && it.masterId == null } ?: return null
|
||||
val occurrences = seriesOccurrences(master, database.tasks().overrides(master.id), clock.now())
|
||||
?: return InstanceRow(master.id, master.id, null, recurring = false)
|
||||
.takeIf { ProviderIds.instanceSeconds(instanceId) == 0L }
|
||||
val occurrence = occurrences.firstOrNull { ProviderIds.instance(master.id, it.anchor) == instanceId } ?: return null
|
||||
return InstanceRow(occurrence.override?.id ?: master.id, master.id, occurrence.anchor, recurring = true)
|
||||
}
|
||||
|
||||
private fun updateInstance(row: InstanceRow, values: ProviderValues): Boolean {
|
||||
val occurrence = row.originalTime
|
||||
if (!row.recurring || occurrence == null) return updateTask(row.taskId, values)
|
||||
val shown = database.tasks().entity(row.taskId)?.takeUnless { it.isDeleted } ?: return false
|
||||
requireWritableList(shown.listId)
|
||||
// A row read back carries every column; only what differs from it is a write.
|
||||
val changed = ProviderTaskWriter.changedColumns(shown, values, zone())
|
||||
val moved = changed intersect TIME_COLUMNS
|
||||
if (moved.isNotEmpty()) {
|
||||
// A bad value reports itself rather than as a move.
|
||||
ProviderTaskWriter.patch(shown, values.filterKeys { it in moved }, clock.now(), zone())
|
||||
throw IllegalArgumentException(
|
||||
"One occurrence of a recurring task cannot be moved (${moved.sorted().joinToString()}); write the series through ${Tasks.PATH}",
|
||||
)
|
||||
}
|
||||
val content = values.filterKeys { it in changed && it !in COMPLETION_COLUMNS }
|
||||
if (content.isNotEmpty()) {
|
||||
updateTask(row.seriesId, content)
|
||||
// A modified occurrence shows its own copy of the content, so it takes the write too.
|
||||
if (row.taskId != row.seriesId) updateTask(row.taskId, content - Tasks.LIST_ID - Tasks.PARENT_ID)
|
||||
}
|
||||
if (changed.none { it in COMPLETION_COLUMNS }) return true
|
||||
// The column the client changed decides, not the unchanged ones it carries along.
|
||||
val change = ProviderTaskWriter.occurrenceChange(values.filterKeys { it in changed && it in COMPLETION_COLUMNS })
|
||||
?: return true
|
||||
val status = database.tasks().entity(row.taskId)?.status
|
||||
when (change) {
|
||||
OccurrenceChange.COMPLETE ->
|
||||
if (status != TaskStatus.COMPLETED) store.setCompletedInstance(row.taskId, occurrence, true)
|
||||
OccurrenceChange.CANCEL ->
|
||||
if (status != TaskStatus.CANCELLED) store.setCancelledInstance(row.taskId, occurrence, true)
|
||||
OccurrenceChange.REOPEN ->
|
||||
if (status != TaskStatus.NEEDS_ACTION) store.setCompletedInstance(row.taskId, occurrence, false)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun deleteInstance(row: InstanceRow): Boolean {
|
||||
val occurrence = row.originalTime
|
||||
if (!row.recurring || occurrence == null) return deleteTask(row.taskId)
|
||||
val task = database.tasks().entity(row.taskId)?.takeUnless { it.isDeleted } ?: return false
|
||||
requireWritableList(task.listId)
|
||||
if (task.masterId != null) store.deleteTask(task.id) else store.deleteOccurrence(task.id, occurrence)
|
||||
return true
|
||||
}
|
||||
|
||||
// --- properties -------------------------------------------------------------
|
||||
|
||||
/** What a published property id stands for, read from the store. */
|
||||
private sealed interface PropertyRef {
|
||||
val task: TaskEntity
|
||||
|
||||
class AlarmRow(val alarm: TaskAlarmEntity, override val task: TaskEntity) : PropertyRef
|
||||
class CategoryRow(val name: String, override val task: TaskEntity) : PropertyRef
|
||||
}
|
||||
|
||||
private fun propertyRef(propertyId: Long): PropertyRef? {
|
||||
if (ProviderIds.isAlarm(propertyId)) {
|
||||
val alarm = database.alarms().entity(ProviderIds.alarmRow(propertyId)) ?: return null
|
||||
val task = database.tasks().entity(alarm.taskId)?.takeUnless { it.isDeleted } ?: return null
|
||||
return PropertyRef.AlarmRow(alarm, task)
|
||||
}
|
||||
val task = database.tasks().entity(ProviderIds.categoryTask(propertyId))?.takeUnless { it.isDeleted } ?: return null
|
||||
val name = ProviderIds.categories(task.id, task.categories).entries.firstOrNull { it.value == propertyId }?.key
|
||||
?: return null
|
||||
return PropertyRef.CategoryRow(name, task)
|
||||
}
|
||||
|
||||
private fun insertProperty(values: ProviderValues): Long {
|
||||
val v = Values(ProviderTaskWriter.writable(ProviderTable.PROPERTIES, values, values.keys))
|
||||
val taskId = requireNotNull(v.long(Properties.TASK_ID)) { "A property needs a ${Properties.TASK_ID}" }
|
||||
val task = liveTask(taskId)
|
||||
return when (v.string(Properties.MIMETYPE)) {
|
||||
Alarm.MIMETYPE -> {
|
||||
val alarm = alarm(TaskAlarmEntity(taskId = taskId, minutesBefore = 0), v, requireMinutes = true)
|
||||
var id = 0L
|
||||
store.writeAlarm(taskId) { id = database.alarms().insert(alarm) }
|
||||
ProviderIds.alarm(id)
|
||||
}
|
||||
Category.MIMETYPE -> {
|
||||
val name = requireNotNull(v.text(Category.NAME)) { "A category needs a ${Category.NAME}" }
|
||||
setCategories(task, task.categories + name)
|
||||
val categories = database.tasks().entity(taskId)!!.categories
|
||||
ProviderIds.categories(taskId, categories).getValue(name)
|
||||
}
|
||||
else -> throw IllegalArgumentException("Only alarm and category properties can be written")
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateProperty(propertyId: Long, values: ProviderValues): Boolean {
|
||||
val v = Values(ProviderTaskWriter.writable(ProviderTable.PROPERTIES, values, values.keys - Properties.PROPERTY_ID))
|
||||
val ref = propertyRef(propertyId) ?: return false
|
||||
requireWritableList(ref.task.listId)
|
||||
requireSame(v, Properties.TASK_ID, ref.task.id)
|
||||
val mimetype = if (ref is PropertyRef.AlarmRow) Alarm.MIMETYPE else Category.MIMETYPE
|
||||
if (Properties.MIMETYPE in v) require(v.string(Properties.MIMETYPE) == mimetype) { "A property's mimetype cannot change" }
|
||||
when (ref) {
|
||||
is PropertyRef.AlarmRow -> {
|
||||
val edited = alarm(ref.alarm, v, requireMinutes = false)
|
||||
if (edited != ref.alarm) store.writeAlarm(ref.task.id) { database.alarms().update(edited) }
|
||||
}
|
||||
is PropertyRef.CategoryRow -> if (Category.NAME in v) {
|
||||
val name = requireNotNull(v.text(Category.NAME)) { "A category needs a ${Category.NAME}" }
|
||||
setCategories(ref.task, ref.task.categories.map { if (it == ref.name) name else it })
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun deleteProperty(propertyId: Long): Boolean {
|
||||
val ref = propertyRef(propertyId) ?: return false
|
||||
requireWritableList(ref.task.listId)
|
||||
when (ref) {
|
||||
is PropertyRef.AlarmRow -> store.writeAlarm(ref.task.id) { database.alarms().delete(ref.alarm.id) }
|
||||
is PropertyRef.CategoryRow -> setCategories(ref.task, ref.task.categories - ref.name)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun alarm(base: TaskAlarmEntity, v: Values, requireMinutes: Boolean): TaskAlarmEntity {
|
||||
if (requireMinutes) requireNotNull(v.int(Alarm.MINUTES_BEFORE)) { "An alarm needs ${Alarm.MINUTES_BEFORE}" }
|
||||
val reference = when (if (Alarm.REFERENCE in v) v.int(Alarm.REFERENCE) else null) {
|
||||
null -> base.reference
|
||||
Alarm.REFERENCE_DUE -> AlarmReference.DUE
|
||||
Alarm.REFERENCE_START -> AlarmReference.START
|
||||
else -> throw IllegalArgumentException("${Alarm.REFERENCE} must be ${Alarm.REFERENCE_DUE} (due) or ${Alarm.REFERENCE_START} (start)")
|
||||
}
|
||||
return base.copy(
|
||||
minutesBefore = if (Alarm.MINUTES_BEFORE in v) requireNotNull(v.int(Alarm.MINUTES_BEFORE)) {
|
||||
"${Alarm.MINUTES_BEFORE} cannot be null"
|
||||
} else base.minutesBefore,
|
||||
reference = reference,
|
||||
message = if (Alarm.MESSAGE in v) v.text(Alarm.MESSAGE) else base.message,
|
||||
)
|
||||
}
|
||||
|
||||
private fun setCategories(task: TaskEntity, categories: List<String>) {
|
||||
val normalized = normalizeTags(categories)
|
||||
if (normalized == task.categories) return
|
||||
store.replaceEntity(task, task.copy(categories = normalized, lastModified = clock.now(), isDirty = true))
|
||||
}
|
||||
|
||||
// --- checks -----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The ids [selection] matches. A row URI's plain `<id column> = N` is checked
|
||||
* against the store directly; anything else runs against the snapshot.
|
||||
*/
|
||||
private fun ids(table: ProviderTable, selection: String?, args: Array<out String>?): List<Long> {
|
||||
singleId(table, selection, args)?.let { id ->
|
||||
val exists = when (table) {
|
||||
ProviderTable.TASKS -> database.tasks().entity(id)?.isDeleted == false
|
||||
ProviderTable.PROPERTIES -> propertyRef(id) != null
|
||||
else -> true
|
||||
}
|
||||
return listOfNotNull(id.takeIf { exists })
|
||||
}
|
||||
return source.rows(table, listOf(table.idColumn), selection, args).map { it.single() as Long }
|
||||
}
|
||||
|
||||
private fun singleId(table: ProviderTable, selection: String?, args: Array<out String>?): Long? {
|
||||
val match = SINGLE_ROW.matchEntire(selection ?: return null) ?: return null
|
||||
if (match.groupValues[1] != table.idColumn) return null
|
||||
val value = match.groupValues[2]
|
||||
return when {
|
||||
value == "?" -> args?.singleOrNull()?.trim()?.toLongOrNull()
|
||||
args.isNullOrEmpty() -> value.toLongOrNull()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun requireFreeUid(listId: Long, uid: String) {
|
||||
require(database.tasks().byUid(listId, uid)?.isDeleted != false) { "List $listId already has a task $uid" }
|
||||
}
|
||||
|
||||
private fun liveTask(taskId: Long): TaskEntity {
|
||||
val task = requireNotNull(database.tasks().entity(taskId)?.takeUnless { it.isDeleted }) { "No task $taskId" }
|
||||
requireWritableList(task.listId)
|
||||
return task
|
||||
}
|
||||
|
||||
private fun requireWritableList(listId: Long) {
|
||||
val list = requireNotNull(database.taskLists().entity(listId)) { "No task list $listId" }
|
||||
if (list.isReadOnly) throw UnsupportedOperationException("Task list $listId is read-only")
|
||||
}
|
||||
|
||||
/** A parent has to be a live, top-level task in the same list, and not one of the task's own subtasks. */
|
||||
private fun requireParent(task: TaskEntity) {
|
||||
var parentId = task.parentId ?: return
|
||||
val parent = database.tasks().entity(parentId)?.takeUnless { it.isDeleted }
|
||||
require(parent != null && parent.masterId == null) { "No task $parentId to be a parent" }
|
||||
require(parent.listId == task.listId) { "A subtask has to be in its parent's list" }
|
||||
val seen = HashSet<Long>()
|
||||
while (true) {
|
||||
require(parentId != task.id) { "Task ${task.id} cannot be its own ancestor" }
|
||||
if (!seen.add(parentId)) break
|
||||
parentId = database.tasks().entity(parentId)?.parentId ?: break
|
||||
}
|
||||
}
|
||||
|
||||
private fun requireSame(v: Values, column: String, expected: Long) {
|
||||
if (column in v) require(v.long(column) == expected) { "A property's $column cannot change" }
|
||||
}
|
||||
|
||||
private fun zone(): String = ZoneId.systemDefault().id
|
||||
}
|
||||
+48
-2
@@ -145,6 +145,12 @@ class RoomTasksDataSource @Inject constructor(
|
||||
.flatMap { occurrencesOf(it, tasks.overrides(it.task.id), now) }
|
||||
}
|
||||
|
||||
/** One transaction, so a failure part-way leaves the subtree whole. */
|
||||
override fun deleteWithSubtasks(taskId: Long) = tx { super.deleteWithSubtasks(taskId) }
|
||||
|
||||
override fun subtaskIds(parentTaskId: Long): List<Long> =
|
||||
tasks.subtasks(parentTaskId).map { it.task.id }.filter { it != parentTaskId }
|
||||
|
||||
override fun exportTasks(listId: Long): List<ExportTask> =
|
||||
tasks.exportTasks(listId).map(RoomTaskMapper::exportTask)
|
||||
|
||||
@@ -343,8 +349,7 @@ class RoomTasksDataSource @Inject constructor(
|
||||
val master = tasks.entity(seriesId) ?: return@tx
|
||||
val anchor = master.recurrenceAnchor
|
||||
if (anchor == null || occurrenceStart <= anchor) {
|
||||
tasks.subtasks(seriesId).forEach { deleteTask(it.task.id) }
|
||||
deleteTask(seriesId)
|
||||
deleteWithSubtasks(seriesId)
|
||||
return@tx
|
||||
}
|
||||
tasks.update(TaskFormWriter.endedBefore(master, occurrenceStart, clock.now(), zone()))
|
||||
@@ -536,6 +541,47 @@ class RoomTasksDataSource @Inject constructor(
|
||||
writes.onWritten(setOf(current.listId))
|
||||
}
|
||||
|
||||
// --- whole-row writes, for the published provider --------------------------
|
||||
|
||||
/** Inserts [entity] as it stands, for a client that wrote the row itself. */
|
||||
internal fun insertEntity(entity: TaskEntity): Long {
|
||||
if (lists.exists(entity.listId) == 0) throw TaskWriteFailedException("insert task: no list ${entity.listId}")
|
||||
return tasks.insert(entity).also { writes.onWritten(setOf(entity.listId)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes [edited] over [current] under the rules [updateTask] and
|
||||
* [updateSeries] keep: a task moves lists through [TaskMove], an override
|
||||
* keeps its master's list and parent, and a series whose anchor or rule
|
||||
* changed drops the overrides it no longer generates.
|
||||
*/
|
||||
internal fun replaceEntity(current: TaskEntity, edited: TaskEntity) {
|
||||
tx {
|
||||
val reshaped = current.masterId == null && current.recurrenceSpec() != null &&
|
||||
(edited.dtstart != current.dtstart || edited.due != current.due || edited.rrule != current.rrule)
|
||||
if (reshaped) tasks.overrides(current.id).forEach { tasks.delete(it.id) }
|
||||
when {
|
||||
current.masterId != null -> tasks.update(edited.copy(listId = current.listId, parentId = current.parentId))
|
||||
edited.listId != current.listId -> {
|
||||
if (lists.exists(edited.listId) == 0) throw TaskWriteFailedException("move task: no list ${edited.listId}")
|
||||
move(current, edited)
|
||||
}
|
||||
else -> tasks.update(edited)
|
||||
}
|
||||
}
|
||||
writes.onWritten(setOf(current.listId, edited.listId))
|
||||
}
|
||||
|
||||
/** Adds, changes or removes one reminder, and marks its task changed so the reminder syncs. */
|
||||
internal fun writeAlarm(taskId: Long, write: () -> Unit) {
|
||||
val task = tasks.entity(taskId) ?: throw TaskWriteFailedException("alarm on task $taskId")
|
||||
tx {
|
||||
write()
|
||||
tasks.update(task.copy(lastModified = clock.now(), isDirty = true))
|
||||
}
|
||||
writes.onWritten(setOf(task.listId))
|
||||
}
|
||||
|
||||
override fun createLocalList(name: String, color: Int): Long =
|
||||
lists.insert(TaskListEntity(name = name.trim(), color = color))
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Update
|
||||
|
||||
/** Reads and writes over `task_alarms`. */
|
||||
@Dao
|
||||
@@ -16,9 +17,15 @@ interface TaskAlarmDao {
|
||||
@Query("SELECT * FROM task_alarms WHERE task_id = :taskId ORDER BY id")
|
||||
fun forTask(taskId: Long): List<TaskAlarmEntity>
|
||||
|
||||
@Query("SELECT * FROM task_alarms WHERE id = :id")
|
||||
fun entity(id: Long): TaskAlarmEntity?
|
||||
|
||||
@Insert
|
||||
fun insert(alarm: TaskAlarmEntity): Long
|
||||
|
||||
@Update
|
||||
fun update(alarm: TaskAlarmEntity): Int
|
||||
|
||||
@Query("DELETE FROM task_alarms WHERE id = :id")
|
||||
fun delete(id: Long): Int
|
||||
|
||||
|
||||
@@ -857,4 +857,7 @@
|
||||
<!-- Permission another app requests to read Agendula's tasks -->
|
||||
<string name="permission_read_tasks_label">read Agendula tasks</string>
|
||||
<string name="permission_read_tasks_description">Allows the app to read your task lists and tasks stored in Agendula, including their reminders and tags.</string>
|
||||
<!-- Permission another app requests to add, change or delete Agendula's tasks -->
|
||||
<string name="permission_write_tasks_label">add, change and delete Agendula tasks</string>
|
||||
<string name="permission_write_tasks_description">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.</string>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
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)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `colliding tags keep their ids whatever their order`() {
|
||||
val hash = { _: String -> 7L }
|
||||
|
||||
assertThat(ProviderIds.categories(5, listOf("b", "a"), hash))
|
||||
.isEqualTo(ProviderIds.categories(5, listOf("a", "b"), hash))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `colliding tags stay inside their task's bits`() {
|
||||
val ids = ProviderIds.categories(5, listOf("a", "b", "c"), hash = { 0x7FFFFFFFL })
|
||||
|
||||
assertThat(ids.values).containsNoDuplicates()
|
||||
ids.values.forEach {
|
||||
assertThat(ProviderIds.categoryTask(it)).isEqualTo(5L)
|
||||
assertThat(ProviderIds.isAlarm(it)).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tags of neighbouring tasks never share an id`() {
|
||||
val names = (1..200).map { "tag$it" }
|
||||
|
||||
val ids = (1L..50L).flatMap { ProviderIds.categories(it, names).values }
|
||||
|
||||
assertThat(ids).containsNoDuplicates()
|
||||
}
|
||||
}
|
||||
|
||||
+45
@@ -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<TaskEntity>, alarms: List<TaskAlarmEntity>) = 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"])
|
||||
}
|
||||
}
|
||||
|
||||
+490
@@ -0,0 +1,490 @@
|
||||
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<String, Any?>) =
|
||||
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<IllegalArgumentException> {
|
||||
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<String, Any?>(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<IllegalArgumentException> {
|
||||
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<IllegalArgumentException> { patch(task, Tasks.PRIORITY to 10) }
|
||||
assertThrows<IllegalArgumentException> { patch(task, Tasks.CLASSIFICATION to 3) }
|
||||
assertThrows<IllegalArgumentException> { patch(task, Tasks.PERCENT_COMPLETE to 101) }
|
||||
assertThrows<IllegalArgumentException> { patch(task, Tasks.STATUS to 4) }
|
||||
assertThrows<IllegalArgumentException> { 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<IllegalArgumentException> { patch(task, Tasks.UID to "other") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a task cannot be its own parent`() {
|
||||
assertThrows<IllegalArgumentException> { 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<IllegalArgumentException> { 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 `a new timed task without a zone is floating`() {
|
||||
val due = millis(Instant.parse("2026-10-20T09:00:00Z"))
|
||||
|
||||
assertThat(ProviderTaskWriter.newTask(mapOf(Tasks.LIST_ID to 3L, Tasks.DUE to due), "u", now, "UTC").timezone).isNull()
|
||||
assertThat(ProviderTaskWriter.newTask(mapOf(Tasks.LIST_ID to 3L, Tasks.DUE to due, Tasks.TZ to "Europe/Paris"), "u", now, "UTC").timezone)
|
||||
.isEqualTo("Europe/Paris")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown zone is rejected`() {
|
||||
assertThrows<IllegalArgumentException> { 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<IllegalArgumentException> {
|
||||
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<IllegalArgumentException> {
|
||||
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<IllegalArgumentException> { 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<IllegalArgumentException> { patch(task, Tasks.DUE to millis(now), Tasks.RRULE to "FREQ=SOMETIMES") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a rule needs a date`() {
|
||||
assertThrows<IllegalArgumentException> { 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<IllegalArgumentException> { patch(override, Tasks.RRULE to "FREQ=DAILY") }
|
||||
assertThat(patch(override, Tasks.RRULE to null, Tasks.RDATE to null, Tasks.EXDATE to "").rrule).isNull()
|
||||
assertThrows<IllegalArgumentException> { patch(override, Tasks.LIST_ID to 2L) }
|
||||
assertThrows<IllegalArgumentException> { 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 completed row written back with status open reopens it fully`() {
|
||||
val done = task.copy(status = TaskStatus.COMPLETED, percentComplete = 100, completedAt = earlier)
|
||||
|
||||
val edited = patch(done, Tasks.STATUS to 0, Tasks.PERCENT_COMPLETE to 100, Tasks.COMPLETED to millis(earlier))
|
||||
|
||||
assertThat(edited.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||
assertThat(edited.percentComplete).isNull()
|
||||
assertThat(edited.completedAt).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the changed completion column decides over unchanged ones written back`() {
|
||||
val completed = patch(task, Tasks.STATUS to 0, Tasks.PERCENT_COMPLETE to 100, Tasks.COMPLETED to null)
|
||||
assertThat(completed.status).isEqualTo(TaskStatus.COMPLETED)
|
||||
assertThat(completed.completedAt).isEqualTo(now)
|
||||
|
||||
val done = task.copy(status = TaskStatus.COMPLETED, percentComplete = 100, completedAt = earlier)
|
||||
val reopened = patch(done, Tasks.STATUS to 2, Tasks.PERCENT_COMPLETE to 100, Tasks.COMPLETED to null)
|
||||
assertThat(reopened.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||
assertThat(reopened.percentComplete).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<IllegalArgumentException> { ProviderTaskWriter.occurrenceChange(mapOf(Tasks.STATUS to 1)) }
|
||||
assertThrows<IllegalArgumentException> { 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<IllegalArgumentException> { 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 `only columns that differ from the row count as changed`() {
|
||||
val due = Instant.parse("2026-10-20T15:00:00Z")
|
||||
val series = task.copy(due = due, timezone = "UTC", rrule = "FREQ=DAILY", status = TaskStatus.IN_PROCESS, percentComplete = 50)
|
||||
val readBack = mapOf(
|
||||
Tasks.TITLE to "Old", 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,
|
||||
Tasks.STATUS to 1, Tasks.PERCENT_COMPLETE to 50, Tasks.COMPLETED to null,
|
||||
)
|
||||
|
||||
assertThat(ProviderTaskWriter.changedColumns(series, readBack, "Europe/Berlin")).isEmpty()
|
||||
assertThat(
|
||||
ProviderTaskWriter.changedColumns(
|
||||
series,
|
||||
readBack + (Tasks.DUE to millis(due + 1.hours)) + (Tasks.TITLE to "New") + (Tasks.STATUS to 2),
|
||||
"Europe/Berlin",
|
||||
),
|
||||
).containsExactly(Tasks.DUE, Tasks.TITLE, Tasks.STATUS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a value that does not fit counts as changed`() {
|
||||
assertThat(ProviderTaskWriter.changedColumns(task, mapOf(Tasks.PRIORITY to 99), "UTC")).containsExactly(Tasks.PRIORITY)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a floating task stays floating`() {
|
||||
val floating = task.copy(due = now, timezone = null)
|
||||
|
||||
assertThat(patch(floating, Tasks.TITLE to "t").timezone).isNull()
|
||||
assertThat(patch(floating, Tasks.DUE to millis(now + 1.hours)).timezone).isNull()
|
||||
assertThat(patch(floating, Tasks.TITLE to "Old")).isSameInstanceAs(floating)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day task made timed takes the device zone`() {
|
||||
val allDay = task.copy(due = Instant.parse("2026-10-20T00:00:00Z"), isAllDay = true)
|
||||
|
||||
val timed = patch(allDay, Tasks.IS_ALLDAY to 0, Tasks.DUE to millis(Instant.parse("2026-10-20T15:00:00Z")))
|
||||
|
||||
assertThat(timed.timezone).isEqualTo("Europe/Berlin")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearing every date clears the zone`() {
|
||||
assertThat(patch(task.copy(due = now, timezone = "UTC"), Tasks.DUE to null).timezone).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exception and extra dates must be iCalendar values of one kind`() {
|
||||
val series = task.copy(due = now, rrule = "FREQ=DAILY", timezone = "UTC")
|
||||
|
||||
assertThat(patch(series, Tasks.EXDATE to "20261016T100000Z, 20261017T100000Z").exdate)
|
||||
.isEqualTo("20261016T100000Z,20261017T100000Z")
|
||||
assertThat(patch(series, Tasks.RDATE to "20261030").rdate).isEqualTo("20261030")
|
||||
assertThrows<IllegalArgumentException> { patch(series, Tasks.EXDATE to "tomorrow") }
|
||||
assertThrows<IllegalArgumentException> { patch(series, Tasks.EXDATE to "20261016,20261017T100000Z") }
|
||||
assertThrows<IllegalArgumentException> { patch(series, Tasks.RDATE to "20261016T100000Z,20261017T100000") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stored dates written back as synced are kept`() {
|
||||
val series = task.copy(due = now, rrule = "FREQ=DAILY", rdate = "20261016T100000Z/PT1H", exdate = "20261016,20261017T100000Z")
|
||||
|
||||
val edited = patch(series, Tasks.TITLE to "New", Tasks.RDATE to series.rdate, Tasks.EXDATE to series.exdate)
|
||||
|
||||
assertThat(edited.rdate).isEqualTo(series.rdate)
|
||||
assertThat(edited.exdate).isEqualTo(series.exdate)
|
||||
assertThat(ProviderTaskWriter.changedColumns(series, mapOf(Tasks.RDATE to series.rdate), "UTC")).isEmpty()
|
||||
}
|
||||
}
|
||||
+78
-13
@@ -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 `<provider android:authorities="de.jeanlucmakiola.agendula.tasks" />` to your `<queries>` |
|
||||
|
||||
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
|
||||
`<package>.tasks` and the permission `<package>.permission.READ_TASKS`.
|
||||
`<package>.tasks` and the permissions are `<package>.permission.READ_TASKS` and
|
||||
`<package>.permission.WRITE_TASKS`.
|
||||
|
||||
| Build | Package |
|
||||
|---|---|
|
||||
@@ -40,15 +42,78 @@ 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. A
|
||||
timed task without a `tz` is floating and stays floating. A task inserted
|
||||
without a `tz` is floating, as in OpenTasks; an existing task that first gets a
|
||||
date takes the device's zone. `rdate` and `exdate` take comma-separated
|
||||
iCalendar values of one kind: all dates, all UTC date-times or all local
|
||||
date-times. A value written back exactly as read is kept, whatever its shape.
|
||||
|
||||
**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. When you write back a row you read, the column you changed
|
||||
decides; the others it carries unchanged do not.
|
||||
|
||||
**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://<authority>` 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.
|
||||
|
||||
+16
-8
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user