Commit 7b5dfda1 authored by Ricki Hirner's avatar Ricki Hirner 🐑

Full support for RELATED-TO

parent 232477ec
Pipeline #95490618 passed with stages
in 4 minutes and 42 seconds
......@@ -24,7 +24,6 @@ import at.bitfire.ical4android.impl.TestEvent
import net.fortuna.ical4j.model.Date
import net.fortuna.ical4j.model.DateList
import net.fortuna.ical4j.model.Dur
import net.fortuna.ical4j.model.Property
import net.fortuna.ical4j.model.component.VAlarm
import net.fortuna.ical4j.model.parameter.Value
import net.fortuna.ical4j.model.property.*
......
......@@ -11,13 +11,16 @@ package at.bitfire.ical4android
import android.accounts.Account
import android.content.ContentUris
import android.content.ContentValues
import androidx.test.filters.MediumTest
import android.database.DatabaseUtils
import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
import at.bitfire.ical4android.impl.TestTask
import at.bitfire.ical4android.impl.TestTaskList
import net.fortuna.ical4j.model.property.RelatedTo
import org.dmfs.tasks.contract.TaskContract
import org.dmfs.tasks.contract.TaskContract.Properties
import org.dmfs.tasks.contract.TaskContract.Property.Relation
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.*
import org.junit.Assume.assumeNotNull
import org.junit.Before
import org.junit.Test
......@@ -43,35 +46,92 @@ class AndroidTaskListTest {
provider?.close()
}
@MediumTest
@Test
fun testManageTaskLists() {
// create task list
private fun createTaskList(): TestTaskList {
val info = ContentValues()
info.put(TaskContract.TaskLists.LIST_NAME, "Test Task List")
info.put(TaskContract.TaskLists.LIST_COLOR, 0xffff0000)
info.put(TaskContract.TaskLists.OWNER, "test@example.com")
info.put(TaskContract.TaskLists.SYNC_ENABLED, 1)
info.put(TaskContract.TaskLists.VISIBLE, 1)
val uri = AndroidTaskList.create(testAccount, provider!!, info)
assertNotNull(uri)
// query task list
val taskList = AndroidTaskList.findByID(testAccount, provider!!, TestTaskList.Factory, ContentUris.parseId(uri))
assertNotNull(taskList)
return AndroidTaskList.findByID(testAccount, provider!!, TestTaskList.Factory, ContentUris.parseId(uri))
}
@Test
fun testManageTaskLists() {
val taskList = createTaskList()
try {
// sync URIs
assertEquals("true", taskList.taskListSyncUri().getQueryParameter(TaskContract.CALLER_IS_SYNCADAPTER))
assertEquals(testAccount.type, taskList.taskListSyncUri().getQueryParameter(TaskContract.ACCOUNT_TYPE))
assertEquals(testAccount.name, taskList.taskListSyncUri().getQueryParameter(TaskContract.ACCOUNT_NAME))
assertEquals("true", taskList.tasksSyncUri().getQueryParameter(TaskContract.CALLER_IS_SYNCADAPTER))
assertEquals(testAccount.type, taskList.tasksSyncUri().getQueryParameter(TaskContract.ACCOUNT_TYPE))
assertEquals(testAccount.name, taskList.tasksSyncUri().getQueryParameter(TaskContract.ACCOUNT_NAME))
} finally {
// delete task list
assertEquals(1, taskList.delete())
}
}
@Test
fun testCommitRelations() {
val taskList = createTaskList()
assertTrue(taskList.useDelayedRelations)
try {
val parent = Task()
parent.uid = "parent"
parent.summary = "Parent task"
val parentContentUri = TestTask(taskList, parent).add()
val child = Task()
child.uid = "child"
child.summary = "Child task"
child.relatedTo.add(RelatedTo(parent.uid))
val childContentUri = TestTask(taskList, child).add()
// there should be one DelayedRelation row
taskList.provider.client.query(taskList.tasksPropertiesSyncUri(), null,
"${Properties.TASK_ID}=?", arrayOf(ContentUris.parseId(childContentUri).toString()),
null, null)!!.use { cursor ->
assertEquals(1, cursor.count)
cursor.moveToNext()
val row = ContentValues()
DatabaseUtils.cursorRowToContentValues(cursor, row)
assertEquals(AndroidTask.DelayedRelation.CONTENT_ITEM_TYPE, row.getAsString(Properties.MIMETYPE))
assertNull(row.getAsLong(Relation.RELATED_ID))
assertEquals(parent.uid, row.getAsString(Relation.RELATED_UID))
assertEquals(Relation.RELTYPE_PARENT, row.getAsInteger(Relation.RELATED_TYPE))
}
taskList.commitRelations()
// sync URIs
assertEquals("true", taskList.taskListSyncUri().getQueryParameter(TaskContract.CALLER_IS_SYNCADAPTER))
assertEquals(testAccount.type, taskList.taskListSyncUri().getQueryParameter(TaskContract.ACCOUNT_TYPE))
assertEquals(testAccount.name, taskList.taskListSyncUri().getQueryParameter(TaskContract.ACCOUNT_NAME))
// now there must be a real Relation row
taskList.provider.client.query(taskList.tasksPropertiesSyncUri(), null,
"${Properties.TASK_ID}=?", arrayOf(ContentUris.parseId(childContentUri).toString()),
null, null)!!.use { cursor ->
assertEquals(1, cursor.count)
cursor.moveToNext()
assertEquals("true", taskList.tasksSyncUri().getQueryParameter(TaskContract.CALLER_IS_SYNCADAPTER))
assertEquals(testAccount.type, taskList.tasksSyncUri().getQueryParameter(TaskContract.ACCOUNT_TYPE))
assertEquals(testAccount.name, taskList.tasksSyncUri().getQueryParameter(TaskContract.ACCOUNT_NAME))
val row = ContentValues()
DatabaseUtils.cursorRowToContentValues(cursor, row)
// delete task list
assertEquals(1, taskList.delete())
assertEquals(Relation.CONTENT_ITEM_TYPE, row.getAsString(Properties.MIMETYPE))
assertEquals(ContentUris.parseId(parentContentUri), row.getAsLong(Relation.RELATED_ID))
assertEquals(parent.uid, row.getAsString(Relation.RELATED_UID))
assertEquals(Relation.RELTYPE_PARENT, row.getAsInteger(Relation.RELATED_TYPE))
}
} finally {
taskList.delete()
}
}
}
......@@ -17,10 +17,8 @@ import at.bitfire.ical4android.impl.TestTask
import at.bitfire.ical4android.impl.TestTaskList
import net.fortuna.ical4j.model.Date
import net.fortuna.ical4j.model.TimeZone
import net.fortuna.ical4j.model.property.DtStart
import net.fortuna.ical4j.model.property.Due
import net.fortuna.ical4j.model.property.Organizer
import net.fortuna.ical4j.model.property.XProperty
import net.fortuna.ical4j.model.parameter.RelType
import net.fortuna.ical4j.model.property.*
import org.dmfs.tasks.contract.TaskContract
import org.junit.After
import org.junit.Assert.*
......@@ -51,6 +49,7 @@ class AndroidTaskTest {
taskList = TestTaskList.create(testAccount, providerOrNull)
assertNotNull("Couldn't find/create test task list", taskList)
taskList!!.useDelayedRelations = false
taskListUri = ContentUris.withAppendedId(provider!!.taskListsUri(), taskList!!.id)
}
......@@ -80,6 +79,11 @@ class AndroidTaskTest {
// extended properties
task.categories.addAll(arrayOf("Cat1", "Cat2"))
val sibling = RelatedTo("most-fields2@example.com")
sibling.parameters.add(RelType.SIBLING)
task.relatedTo.add(sibling)
task.unknownProperties += XProperty("X-UNKNOWN-PROP", "Unknown Value")
// add to task list
......@@ -98,7 +102,9 @@ class AndroidTaskTest {
assertEquals(task.description, task2.description)
assertEquals(task.location, task2.location)
assertEquals(task.dtStart, task2.dtStart)
assertEquals(task.categories, task2.categories)
assertEquals(task.relatedTo, task2.relatedTo)
assertEquals(task.unknownProperties, task2.unknownProperties)
} finally {
testTask.delete()
......
......@@ -10,21 +10,23 @@ package at.bitfire.ical4android
import android.content.ContentProviderOperation
import android.content.ContentProviderOperation.Builder
import android.content.ContentResolver
import android.content.ContentUris
import android.content.ContentValues
import android.net.Uri
import android.os.RemoteException
import at.bitfire.ical4android.AndroidTask.DelayedRelation.Companion.CONTENT_ITEM_TYPE
import at.bitfire.ical4android.MiscUtils.CursorHelper.toValues
import net.fortuna.ical4j.model.*
import net.fortuna.ical4j.model.Date
import net.fortuna.ical4j.model.Property
import net.fortuna.ical4j.model.component.VAlarm
import net.fortuna.ical4j.model.parameter.RelType
import net.fortuna.ical4j.model.parameter.Related
import net.fortuna.ical4j.model.property.*
import org.dmfs.tasks.contract.TaskContract.*
import org.dmfs.tasks.contract.TaskContract.Properties
import org.dmfs.tasks.contract.TaskContract.Property.Alarm
import org.dmfs.tasks.contract.TaskContract.Property.Category
import org.dmfs.tasks.contract.TaskContract.Property.*
import java.io.FileNotFoundException
import java.net.URI
import java.net.URISyntaxException
......@@ -92,6 +94,26 @@ abstract class AndroidTask(
populateProperty(propCursor.toValues(true))
}
// Special case: parent_id set, but no matching parent Relation row (like given by aCalendar+)
// In this case, we create the relation ourselves.
val relatedToList = task!!.relatedTo
values.getAsLong(Tasks.PARENT_ID)?.let { parentId ->
val hasParentRelation = relatedToList.any { relatedTo ->
val relatedType = relatedTo.getParameter(Parameter.RELTYPE)
relatedType == null || relatedType == RelType.PARENT
}
if (!hasParentRelation) {
// get UID of parent task
val parentContentUri = ContentUris.withAppendedId(taskList.tasksSyncUri(), parentId)
client.query(parentContentUri, arrayOf(Tasks._UID), null, null, null)?.use { cursor ->
if (cursor.moveToNext()) {
// add RelatedTo for parent task
relatedToList += RelatedTo(cursor.getString(0))
}
}
}
}
return task
}
}
......@@ -189,6 +211,8 @@ abstract class AndroidTask(
populateAlarm(row)
Category.CONTENT_ITEM_TYPE ->
task.categories += row.getAsString(Category.CATEGORY_NAME)
Relation.CONTENT_ITEM_TYPE ->
populateRelatedTo(row)
UnknownProperty.CONTENT_ITEM_TYPE ->
task.unknownProperties += UnknownProperty.fromJsonString(row.getAsString(UNKNOWN_PROPERTY_DATA))
else ->
......@@ -197,7 +221,6 @@ abstract class AndroidTask(
}
protected open fun populateAlarm(row: ContentValues) {
Constants.log.log(Level.FINE, "Read task reminder from tasks provider", row)
val task = requireNotNull(task)
val props = PropertyList<Property>()
......@@ -225,6 +248,28 @@ abstract class AndroidTask(
task.alarms += VAlarm(props)
}
protected open fun populateRelatedTo(row: ContentValues) {
val uid = row.getAsString(Relation.RELATED_UID)
if (uid == null) {
Constants.log.warning("Task relation doesn't refer to same task list; can't be synchronized")
return
}
val relatedTo = RelatedTo(uid)
// add relation type as reltypeparam
relatedTo.parameters.add(when (row.getAsInteger(Relation.RELATED_TYPE)) {
Relation.RELTYPE_CHILD ->
RelType.CHILD
Relation.RELTYPE_SIBLING ->
RelType.SIBLING
else /* Relation.RELTYPE_PARENT, default value */ ->
RelType.PARENT
})
requireNotNull(task).relatedTo.add(relatedTo)
}
fun add(): Uri {
val batch = BatchOperation(taskList.provider.client)
......@@ -261,13 +306,14 @@ abstract class AndroidTask(
return uri
}
private fun insertProperties(batch: BatchOperation) {
protected open fun insertProperties(batch: BatchOperation) {
insertAlarms(batch)
insertCategories(batch)
insertRelatedTo(batch)
insertUnknownProperties(batch)
}
private fun insertAlarms(batch: BatchOperation) {
protected open fun insertAlarms(batch: BatchOperation) {
for (alarm in requireNotNull(task).alarms) {
val alarmRef = when (alarm.trigger.getParameter(Parameter.RELATED)) {
Related.END ->
......@@ -300,7 +346,7 @@ abstract class AndroidTask(
}
}
private fun insertCategories(batch: BatchOperation) {
protected open fun insertCategories(batch: BatchOperation) {
for (category in requireNotNull(task).categories) {
val builder = ContentProviderOperation.newInsert(taskList.tasksPropertiesSyncUri())
.withValue(Category.TASK_ID, id)
......@@ -311,7 +357,32 @@ abstract class AndroidTask(
}
}
private fun insertUnknownProperties(batch: BatchOperation) {
protected open fun insertRelatedTo(batch: BatchOperation) {
val mimeType = if (taskList.useDelayedRelations)
DelayedRelation.CONTENT_ITEM_TYPE
else
Relation.CONTENT_ITEM_TYPE
for (relatedTo in requireNotNull(task).relatedTo) {
val relType = when ((relatedTo.getParameter(Parameter.RELTYPE) as RelType?)) {
RelType.CHILD ->
Relation.RELTYPE_CHILD
RelType.SIBLING ->
Relation.RELTYPE_SIBLING
else /* RelType.PARENT, default value */ ->
Relation.RELTYPE_PARENT
}
val builder = ContentProviderOperation.newInsert(taskList.tasksPropertiesSyncUri())
.withValue(Relation.TASK_ID, id)
.withValue(Relation.MIMETYPE, mimeType)
.withValue(Relation.RELATED_UID, relatedTo.value)
.withValue(Relation.RELATED_TYPE, relType)
Constants.log.log(Level.FINE, "Inserting relation", builder.build())
batch.enqueue(BatchOperation.Operation(builder))
}
}
protected open fun insertUnknownProperties(batch: BatchOperation) {
for (property in requireNotNull(task).unknownProperties) {
if (property.value.length > UnknownProperty.MAX_UNKNOWN_PROPERTY_SIZE) {
Constants.log.warning("Ignoring unknown property with ${property.value.length} octets (too long)")
......@@ -434,4 +505,17 @@ abstract class AndroidTask(
override fun toString() = MiscUtils.reflectionToString(this)
/**
* A delayed relation row represents a relation which possibly can't be resolved yet.
* Same definition as [Relation], only the row type is [CONTENT_ITEM_TYPE] instead of [Relation.CONTENT_ITEM_TYPE].
*/
class DelayedRelation {
companion object {
const val CONTENT_ITEM_TYPE = ContentResolver.CURSOR_ITEM_BASE_TYPE + "/vnd.ical4android.delayed-relation"
}
}
}
......@@ -9,14 +9,16 @@
package at.bitfire.ical4android
import android.accounts.Account
import android.content.ContentProviderOperation
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.net.Uri
import at.bitfire.ical4android.MiscUtils.CursorHelper.toValues
import org.dmfs.tasks.contract.TaskContract
import org.dmfs.tasks.contract.TaskContract.TaskLists
import org.dmfs.tasks.contract.TaskContract.Tasks
import org.dmfs.tasks.contract.TaskContract.*
import org.dmfs.tasks.contract.TaskContract.Properties
import org.dmfs.tasks.contract.TaskContract.Property.Relation
import java.io.FileNotFoundException
import java.util.*
......@@ -38,8 +40,9 @@ abstract class AndroidTaskList<out T: AndroidTask>(
/**
* Acquires a [android.content.ContentProviderClient] for a supported task provider. If multiple providers are
* available, a pre-defined priority list is taken into account.
*
* @return A [TaskProvider], or null if task storage is not available/accessible.
* Caller is responsible for calling release()!
* Caller is responsible for calling [TaskProvider.close]!
*/
fun acquireTaskProvider(context: Context): TaskProvider? {
val byPriority = arrayOf(
......@@ -96,6 +99,16 @@ abstract class AndroidTaskList<out T: AndroidTask>(
var isSynced = false
var isVisible = false
/**
* When tasks are added or updated, they may refer to related tasks ([Task.relatedTo]),
* but these related tasks may not be available yet (for instance, because they have not been
* synchronized yet), so that the tasks provider can't establish the relation in the database.
*
* When delayed relations are used, [commitRelations] must be called after
* operations which potentially add relations (namely [AndroidTask.add] and [AndroidTask.update]).
*/
var useDelayedRelations = true
protected fun populate(values: ContentValues) {
syncId = values.getAsString(TaskLists._SYNC_ID)
......@@ -108,12 +121,50 @@ abstract class AndroidTaskList<out T: AndroidTask>(
fun update(info: ContentValues) = provider.client.update(taskListSyncUri(), info, null, null)
fun delete() = provider.client.delete(taskListSyncUri(), null, null)
/**
* Transforms [AndroidTask.DelayedRelation]s to real [org.dmfs.tasks.contract.TaskContract.Property.Relation]s. Only
* useful when [useDelayedRelations] is active.
*/
fun commitRelations() {
Constants.log.fine("Commiting relations")
val batch = BatchOperation(provider.client)
provider.client.query(tasksPropertiesSyncUri(),
arrayOf(Properties.PROPERTY_ID, Properties.TASK_ID, Relation.RELATED_TYPE, Relation.RELATED_UID),
"${Properties.MIMETYPE}=?", arrayOf(AndroidTask.DelayedRelation.CONTENT_ITEM_TYPE), null)?.use { cursor ->
while (cursor.moveToNext()) {
val id = cursor.getLong(0)
val taskId = cursor.getLong(1)
val relatedType = cursor.getInt(2)
val relatedUid = cursor.getString(3)
// create new Relation row
batch.enqueue(BatchOperation.Operation(
ContentProviderOperation.newInsert(tasksPropertiesSyncUri())
.withValue(Relation.TASK_ID, taskId)
.withValue(Relation.MIMETYPE, Relation.CONTENT_ITEM_TYPE)
.withValue(Relation.RELATED_TYPE, relatedType)
.withValue(Relation.RELATED_UID, relatedUid)
))
// delete DelayedRelation row
val delayedRelationUri = ContentUris.withAppendedId(tasksPropertiesSyncUri(), id)
batch.enqueue(BatchOperation.Operation(
ContentProviderOperation.newDelete(delayedRelationUri)
))
}
}
batch.commit()
}
/**
* Queries tasks from this task list. Adds a WHERE clause that restricts the
* query to [Tasks.LIST_ID] = [id].
*
* @param _where selection
* @param _whereArgs arguments for selection
*
* @return events from this task list which match the selection
*/
fun queryTasks(_where: String? = null, _whereArgs: Array<String>? = null): List<T> {
......
......@@ -55,10 +55,12 @@ class Task: ICalendar() {
val rDates = LinkedList<RDate>()
val exDates = LinkedList<ExDate>()
val alarms = LinkedList<VAlarm>()
val categories = LinkedList<String>()
var relatedTo = LinkedList<RelatedTo>()
val unknownProperties = LinkedList<Property>()
val alarms = LinkedList<VAlarm>()
companion object {
/**
......@@ -120,7 +122,8 @@ class Task: ICalendar() {
is RDate -> t.rDates += prop
is ExDate -> t.exDates += prop
is Categories -> t.categories.addAll(prop.categories.toList())
is ProdId, is DtStamp, is Uid -> { /* don't save these as unknown properties */ }
is RelatedTo -> t.relatedTo.add(prop)
is Uid, is ProdId, is DtStamp -> { /* don't save these as unknown properties */ }
else -> t.unknownProperties += prop
}
......@@ -174,7 +177,7 @@ class Task: ICalendar() {
if (categories.isNotEmpty())
props += Categories(TextList(categories.toTypedArray()))
props.addAll(relatedTo)
props.addAll(unknownProperties)
// remember used time zones
......
......@@ -8,10 +8,8 @@
package at.bitfire.ical4android
import net.fortuna.ical4j.model.Date
import net.fortuna.ical4j.model.DateList
import net.fortuna.ical4j.model.DateTime
import net.fortuna.ical4j.model.Dur
import net.fortuna.ical4j.model.*
import net.fortuna.ical4j.model.parameter.RelType
import net.fortuna.ical4j.model.parameter.Value
import net.fortuna.ical4j.model.property.*
import org.junit.Assert.*
......@@ -90,6 +88,10 @@ class TaskTest {
assertArrayEquals(arrayOf("Test","Sample"), t.categories.toArray())
val sibling = t.relatedTo.first
assertEquals("most-fields2@example.com", sibling.value)
assertEquals(RelType.SIBLING, (sibling.getParameter(Parameter.RELTYPE) as RelType))
val unknown = t.unknownProperties.first
assertEquals("X-UNKNOWN-PROP", unknown.name)
assertEquals("xxx", unknown.getParameter("param1").value)
......
......@@ -24,6 +24,7 @@ EXDATE;VALUE=DATE:20120101
EXDATE;VALUE=DATE:20140101,20180101
RDATE;VALUE=DATE:20100310,20100315
RDATE;VALUE=DATE:20100810
RELATED-TO;RELTYPE=SIBLING:most-fields2@example.com
X-UNKNOWN-PROP;param1=xxx:Unknown Value
CREATED:19960329T133000Z
LAST-MODIFIED:19960817T133000Z
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment