diff --git a/lib/src/androidTest/kotlin/at/bitfire/ical4android/DmfsTaskTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/DmfsTaskTest.kt index a6d3fa5d..81b6ba08 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/ical4android/DmfsTaskTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/DmfsTaskTest.kt @@ -107,10 +107,10 @@ class DmfsTaskTest( assertNotNull("Couldn't add task", uri) // read and parse event from calendar provider - val testTask = taskList!!.getTask(ContentUris.parseId(uri)) + val testTask = taskList!!.getLegacyTask(ContentUris.parseId(uri)) try { assertNotNull("Inserted task is not here", testTask) - val task2 = testTask.task + val task2 = testTask?.task assertNotNull("Inserted task is empty", task2) // compare with original event @@ -124,7 +124,7 @@ class DmfsTaskTest( assertEquals(task.relatedTo, task2.relatedTo) assertEquals(task.unknownProperties, task2.unknownProperties) } finally { - testTask.delete() + testTask?.delete() } } @@ -151,7 +151,7 @@ class DmfsTaskTest( val uri = DmfsTask(taskList!!, task, "9468a4cf-0d5b-4379-a704-12f1f84100ba", null, 0).add() val task2 = taskList!!.getTask(ContentUris.parseId(uri)) - assertEquals(1050, task2.task?.alarms?.size) + assertEquals(1050, task2?.task?.alarms?.size) } @Test @@ -170,20 +170,20 @@ class DmfsTaskTest( val testTask = taskList!!.getTask(ContentUris.parseId(uri)) try { // update test event in calendar - val task2 = testTask.task!! + val task2 = testTask?.task!! task2.summary = "Updated event" // change value task.location = null // remove value task2.duration = Duration(java.time.Duration.ofMinutes(10)) // add value testTask.update(task2) // read again and verify result - val updatedTask = taskList!!.getTask(ContentUris.parseId(uri)).task!! + val updatedTask = taskList!!.getTask(ContentUris.parseId(uri))?.task!! assertEquals(task2.summary, updatedTask.summary) assertEquals(task2.location, updatedTask.location) assertEquals(task2.dtStart, updatedTask.dtStart) assertEquals(task2.duration!!.value, updatedTask.duration!!.value) } finally { - testTask.delete() + testTask?.delete() } } diff --git a/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/tasks/DmfsTaskBuilderTest.kt b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/tasks/DmfsTaskBuilderTest.kt index 8e467979..4fb584f6 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/tasks/DmfsTaskBuilderTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/tasks/DmfsTaskBuilderTest.kt @@ -734,7 +734,7 @@ class DmfsTaskBuilderTest ( val testTask = taskList!!.getTask(ContentUris.parseId(uri)) try { // read again and verify result - val task2 = testTask.task!! + val task2 = testTask?.task!! assertEquals(task.summary, task2.summary) assertEquals(task.description, task2.description) assertEquals(task.location, task2.location) @@ -742,7 +742,7 @@ class DmfsTaskBuilderTest ( assertEquals(task.due!!.date, task2.due!!.date) Assert.assertTrue(task2.isAllDay()) } finally { - testTask.delete() + testTask?.delete() } } diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/DmfsTask.kt b/lib/src/main/kotlin/at/bitfire/ical4android/DmfsTask.kt index 04578330..fbf16c93 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/DmfsTask.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/DmfsTask.kt @@ -35,6 +35,7 @@ import java.util.logging.Logger * The SEQUENCE field is stored in [Tasks.SYNC_VERSION], so don't use [Tasks.SYNC_VERSION] * for anything else. */ +@Deprecated("Use storage.tasks.DmfsTask instead") class DmfsTask( val taskList: DmfsTaskList ) { diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/DmfsTaskBuilder.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/DmfsTaskBuilder.kt index fab70455..2434e533 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/DmfsTaskBuilder.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/DmfsTaskBuilder.kt @@ -6,13 +6,13 @@ package at.bitfire.synctools.mapping.tasks -import at.bitfire.ical4android.DmfsTask.Companion.COLUMN_ETAG -import at.bitfire.ical4android.DmfsTask.Companion.COLUMN_FLAGS -import at.bitfire.ical4android.DmfsTask.Companion.UNKNOWN_PROPERTY_DATA import at.bitfire.ical4android.ICalendar import at.bitfire.ical4android.Task import at.bitfire.ical4android.UnknownProperty import at.bitfire.synctools.storage.BatchOperation.CpoBuilder +import at.bitfire.synctools.storage.tasks.DmfsTask.Companion.COLUMN_ETAG +import at.bitfire.synctools.storage.tasks.DmfsTask.Companion.COLUMN_FLAGS +import at.bitfire.synctools.storage.tasks.DmfsTask.Companion.UNKNOWN_PROPERTY_DATA import at.bitfire.synctools.storage.tasks.DmfsTaskList import at.bitfire.synctools.storage.tasks.TasksBatchOperation import at.bitfire.synctools.util.AndroidTimeUtils diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/DmfsTaskProcessor.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/DmfsTaskProcessor.kt index 7a775f8f..e685aee2 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/DmfsTaskProcessor.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/DmfsTaskProcessor.kt @@ -7,9 +7,9 @@ package at.bitfire.synctools.mapping.tasks import android.content.ContentValues -import at.bitfire.ical4android.DmfsTask.Companion.UNKNOWN_PROPERTY_DATA import at.bitfire.ical4android.Task import at.bitfire.ical4android.UnknownProperty +import at.bitfire.synctools.storage.tasks.DmfsTask.Companion.UNKNOWN_PROPERTY_DATA import at.bitfire.synctools.storage.tasks.DmfsTaskList import at.bitfire.synctools.util.AndroidTimeUtils import net.fortuna.ical4j.model.Date diff --git a/lib/src/main/kotlin/at/bitfire/synctools/storage/tasks/DmfsTask.kt b/lib/src/main/kotlin/at/bitfire/synctools/storage/tasks/DmfsTask.kt new file mode 100644 index 00000000..6e8c10eb --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/storage/tasks/DmfsTask.kt @@ -0,0 +1,210 @@ +/* + * This file is part of bitfireAT/synctools which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.synctools.storage.tasks + +import android.content.ContentUris +import android.content.ContentValues +import android.content.Entity +import android.net.Uri +import at.bitfire.ical4android.Task +import at.bitfire.synctools.mapping.tasks.DmfsTaskBuilder +import at.bitfire.synctools.mapping.tasks.DmfsTaskProcessor +import at.bitfire.synctools.storage.BatchOperation +import at.bitfire.synctools.storage.LocalStorageException +import at.bitfire.synctools.storage.toContentValues +import net.fortuna.ical4j.model.Parameter +import net.fortuna.ical4j.model.parameter.RelType +import net.fortuna.ical4j.model.property.RelatedTo +import org.dmfs.tasks.contract.TaskContract +import java.io.FileNotFoundException +import java.util.logging.Level +import java.util.logging.Logger + +/** + * Stores and retrieves tasks to/from the tasks.org-content provider (currently tasks.org and + * OpenTasks). + * + * A task in the context of this class is one row in the [org.dmfs.tasks.contract.TaskContract.Tasks] table, + * plus associated data rows (like alarms and reminders). + * + * Exceptions (of recurring tasks) have their own entries in the [org.dmfs.tasks.contract.TaskContract.Tasks] table and thus + * are separate. + * + * The SEQUENCE field is stored in [org.dmfs.tasks.contract.TaskContract.CommonSyncColumns.SYNC_VERSION], so don't use [org.dmfs.tasks.contract.TaskContract.CommonSyncColumns.SYNC_VERSION] + * for anything else. + * + * @param taskList task list where the task is stored + * @param values entity with all columns, as returned by the calendar provider; [org.dmfs.tasks.contract.TaskContract.Tasks._ID] must be set to a non-null value + */ +class DmfsTask( + val taskList: DmfsTaskList, + val values: Entity +) { + + private val logger = Logger.getLogger(javaClass.name) + + private val mainValues + get() = values.entityValues + + var id: Long = mainValues.getAsLong(TaskContract.Tasks._ID) + var syncId: String? = mainValues.getAsString(TaskContract.Tasks._SYNC_ID) + var eTag: String? = mainValues.getAsString(COLUMN_ETAG) + var flags: Int = mainValues.getAsInteger(COLUMN_FLAGS) ?: 0 + + var task: Task? = null + /** + * This getter returns the full task data, either from [task] or, if [task] is null, by reading task + * number [id] from the task provider + * @throws IllegalArgumentException if task has not been saved yet + * @throws java.io.FileNotFoundException if there's no task with [id] in the task provider + * @throws android.os.RemoteException on task provider errors + */ + get() { + if (field != null) + return field + val id = requireNotNull(id) + + try { + val client = taskList.provider.client + client.query(taskSyncURI(true), null, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + // create new Task which will be populated + val newTask = Task() + field = newTask + + val values = cursor.toContentValues() + logger.log(Level.FINER, "Found task", values) + val processor = DmfsTaskProcessor(taskList) + processor.populateTask(values, newTask) + + if (values.containsKey(TaskContract.Properties.PROPERTY_ID)) { + // process the first property, which is combined with the task row + processor.populateProperty(values, newTask) + + while (cursor.moveToNext()) { + // process the other properties + processor.populateProperty(cursor.toContentValues(), newTask) + } + } + + // Special case: parent_id set, but no matching parent Relation row (like given by aCalendar+) + val relatedToList = newTask.relatedTo + values.getAsLong(TaskContract.Tasks.PARENT_ID)?.let { parentId -> + val hasParentRelation = relatedToList.any { relatedTo -> + val relatedType = relatedTo.getParameter(Parameter.RELTYPE) + relatedType == RelType.PARENT || relatedType == null /* RelType.PARENT is the default value */ + } + if (!hasParentRelation) { + // get UID of parent task + val parentContentUri = ContentUris.withAppendedId(taskList.tasksUri(), parentId) + client.query(parentContentUri, arrayOf(TaskContract.Tasks._UID), null, null, null)?.use { cursor -> + if (cursor.moveToNext()) { + // add RelatedTo for parent task + relatedToList += RelatedTo(cursor.getString(0)) + } + } + } + } + + field = newTask + return newTask + } + } + } catch (e: Exception) { + /* Populating event has been interrupted by an exception, so we reset the event to + avoid an inconsistent state. This also ensures that the exception will be thrown + again on the next get() call. */ + field = null + throw e + } + throw FileNotFoundException("Couldn't find task #$id") + } + + /** + * Saves the unsaved [task] into the task provider storage. + * + * @return content URI of the created task + * + * @throws at.bitfire.synctools.storage.LocalStorageException when the tasks provider doesn't return a result row + * @throws android.os.RemoteException on tasks provider errors + */ + fun add(): Uri { + val batch = TasksBatchOperation(taskList.provider.client) + + val requiredTask = requireNotNull(task) + val builder = DmfsTaskBuilder(taskList, requiredTask, id, syncId, eTag, flags) + val idxTask = builder.addRows(batch) + builder.insertProperties(batch, idxTask) + + batch.commit() + + val resultUri = batch.getResult(0)?.uri + ?: throw LocalStorageException("Empty result from provider when adding a task") + id = ContentUris.parseId(resultUri) + return resultUri + } + + /** + * Updates an already existing task in the tasks provider storage with the values + * from the instance. + * + * @return content URI of the updated task + * + * @throws LocalStorageException when the tasks provider doesn't return a result row + * @throws android.os.RemoteException on tasks provider errors + */ + fun update(task: Task): Uri { + this.task = task + val existingId = requireNotNull(id) + + val batch = TasksBatchOperation(taskList.provider.client) + + // remove associated rows which are added later again + batch += BatchOperation.CpoBuilder + .newDelete(taskList.tasksPropertiesUri()) + .withSelection("${TaskContract.Properties.TASK_ID}=?", arrayOf(existingId.toString())) + + // update task + val builder = DmfsTaskBuilder(taskList, task, id, syncId, eTag, flags) + builder.updateRows(batch) + + // insert task properties again + builder.insertProperties(batch, null) + + batch.commit() + return ContentUris.withAppendedId(TaskContract.Tasks.getContentUri(taskList.providerName.authority), existingId) + } + + fun update(values: ContentValues) { + taskList.provider.client.update(taskSyncURI(), values, null, null) + } + + /** + * Deletes an existing task from the tasks provider storage. + * + * @return number of affected rows + * + * @throws android.os.RemoteException on tasks provider errors + */ + fun delete(): Int { + return taskList.provider.client.delete(taskSyncURI(), null, null) + } + + private fun taskSyncURI(loadProperties: Boolean = false): Uri { + val id = requireNotNull(id) + return ContentUris.withAppendedId(taskList.tasksUri(loadProperties), id) + } + + companion object { + const val UNKNOWN_PROPERTY_DATA = TaskContract.Properties.DATA0 + + const val COLUMN_ETAG = TaskContract.Tasks.SYNC1 + + const val COLUMN_FLAGS = TaskContract.Tasks.SYNC2 + } + +} \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/synctools/storage/tasks/DmfsTaskList.kt b/lib/src/main/kotlin/at/bitfire/synctools/storage/tasks/DmfsTaskList.kt index d72bc4c8..1ab17446 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/storage/tasks/DmfsTaskList.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/storage/tasks/DmfsTaskList.kt @@ -8,9 +8,9 @@ package at.bitfire.synctools.storage.tasks import android.content.ContentUris import android.content.ContentValues +import android.content.Entity import android.net.Uri import android.os.RemoteException -import at.bitfire.ical4android.DmfsTask import at.bitfire.ical4android.TaskProvider import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter import at.bitfire.synctools.storage.BatchOperation @@ -19,9 +19,10 @@ import at.bitfire.synctools.storage.toContentValues import org.dmfs.tasks.contract.TaskContract import java.util.LinkedList import java.util.logging.Logger +import at.bitfire.ical4android.DmfsTask as LegacyDmfsTask /** - * Represents a locally stored task list, containing [at.bitfire.ical4android.DmfsTask]s (tasks). + * Represents a locally stored task list, containing [DmfsTask]s (tasks). * Communicates with tasks.org-compatible content providers (currently tasks.org and OpenTasks) to store the tasks. */ class DmfsTaskList( @@ -59,15 +60,19 @@ class DmfsTaskList( * @param where selection * @param whereArgs arguments for selection * - * @return events from this task list which match the selection + * @return tasks from this task list which match the selection */ fun findTasks(where: String? = null, whereArgs: Array? = null): List { val tasks = LinkedList() try { val (protectedWhere, protectedWhereArgs) = whereWithTaskListId(where, whereArgs) - client.query(tasksUri(), null, protectedWhere, protectedWhereArgs, null)?.use { cursor -> - while (cursor.moveToNext()) - tasks += DmfsTask(this, cursor.toContentValues()) + client.query(tasksUri(), arrayOf(TaskContract.Tasks._ID), protectedWhere, protectedWhereArgs, null)?.use { cursor -> + while (cursor.moveToNext()) { + val taskId = cursor.getLong(0) + val entity = getTaskEntity(taskId) + if (entity != null) + tasks += DmfsTask(this, entity) + } } } catch (e: RemoteException) { throw LocalStorageException("Couldn't query ${providerName.authority} tasks", e) @@ -75,9 +80,56 @@ class DmfsTaskList( return tasks } - fun getTask(id: Long) = findTasks("${TaskContract.Tasks._ID}=?", arrayOf(id.toString())).firstOrNull() - ?: throw LocalStorageException("Couldn't query ${providerName.authority} tasks") - + /** + * Gets a task from this task list by given id. + * + * @return task from this task list which matches the selection + */ + fun getTask(id: Long): DmfsTask? { + val values = getTaskEntity(id) ?: return null + return DmfsTask(this, values) + } + + /** + * Retrieves a task as entity from this task list by given id and selection. + * + * @param where selection + * @param whereArgs arguments for selection + * + * @return task from this task list which matches the selection + */ + fun getTaskEntity(id: Long, where: String? = null, whereArgs: Array? = null): Entity? { + try { + client.query(taskUri(id, true), null, where, whereArgs, null)?.use { cursor -> + if (cursor.moveToFirst()) { + // first row holds entity main values + val entity = Entity(cursor.toContentValues()) + // remaining rows hold entity subvalues (extended properties) + while (cursor.moveToNext()) { + val cv = cursor.toContentValues() + val mimetype = cv.getAsString(TaskContract.PropertyColumns.MIMETYPE) // CONTENT_ITEM_TYPE of extended property + entity.addSubValue(tasksPropertyUri(mimetype), cv) + } + return entity + } + } + } catch (e: RemoteException) { + throw LocalStorageException("Couldn't query task entity", e) + } + return null + } + + /** + * Gets a specific task, identified by its ID, from this task list. + * + * @param id task ID + * @return task (or `null` if not found) + */ + fun getLegacyTask(id: Long): LegacyDmfsTask? { + val entity = getTaskEntity(id, null) ?: return null + return LegacyDmfsTask(this, entity.entityValues) + } + /** * Updates tasks in this task list. * @@ -155,6 +207,9 @@ class DmfsTaskList( fun tasksPropertiesUri() = TaskContract.Properties.getContentUri(providerName.authority).asSyncAdapter(account) + fun tasksPropertyUri(mimetype: String): Uri = + tasksPropertiesUri().buildUpon().appendPath(mimetype).build()!! + /** * Restricts a given selection/where clause to this task list ID. *