Commit 1925b714 authored by Ricki Hirner's avatar Ricki Hirner 🐑
Browse files

Tasks: support saving/restoring unknown properties

parent 4c871398
Pipeline #94957475 passed with stages
in 5 minutes and 6 seconds
......@@ -24,6 +24,7 @@ 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.*
......@@ -115,6 +116,10 @@ class AndroidEventTest {
// add EXDATE
event.exDates += ExDate(DateList("20150502T120000", Value.DATE_TIME, tzVienna))
// add special properties
event.unknownProperties.add(Categories("CAT1,CAT2"))
event.unknownProperties.add(XProperty("X-NAME", "X-Value"))
// add to calendar
val uri = TestEvent(calendar, event).add()
assertNotNull(uri)
......@@ -172,6 +177,9 @@ class AndroidEventTest {
// compare EXDATE
assertEquals(1, event2.exDates.size)
assertEquals(event.exDates.first, event2.exDates.first)
// compare unknown properties
assertArrayEquals(event.unknownProperties.toArray(), event2.unknownProperties.toArray())
} finally {
testEvent.delete()
}
......
......@@ -20,6 +20,7 @@ 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 org.dmfs.tasks.contract.TaskContract
import org.junit.After
import org.junit.Assert.*
......@@ -77,6 +78,10 @@ class AndroidTaskTest {
task.organizer = Organizer("mailto:organizer@example.com")
assertFalse(task.isAllDay())
// extended properties
task.categories.addAll(arrayOf("Cat1", "Cat2"))
task.unknownProperties += XProperty("X-UNKNOWN-PROP", "Unknown Value")
// add to task list
val uri = TestTask(taskList!!, task).add()
assertNotNull("Couldn't add task", uri)
......@@ -93,6 +98,8 @@ class AndroidTaskTest {
assertEquals(task.description, task2.description)
assertEquals(task.location, task2.location)
assertEquals(task.dtStart, task2.dtStart)
assertEquals(task.categories, task2.categories)
assertEquals(task.unknownProperties, task2.unknownProperties)
} finally {
testTask.delete()
}
......
......@@ -14,8 +14,8 @@ class UnknownPropertyTest {
@Test
@SmallTest
fun testFromExtendedProperty() {
val prop = AndroidEvent.UnknownProperty.fromExtendedProperty("[ \"UID\", \"PropValue\" ]")
fun testFromJsonString() {
val prop = UnknownProperty.fromJsonString("[ \"UID\", \"PropValue\" ]")
assertTrue(prop is Uid)
assertEquals("UID", prop.name)
assertEquals("PropValue", prop.value)
......@@ -23,8 +23,8 @@ class UnknownPropertyTest {
@Test
@SmallTest
fun testFromExtendedPropertyWithParameters() {
val prop = AndroidEvent.UnknownProperty.fromExtendedProperty("[ \"ATTENDEE\", \"PropValue\", { \"x-param1\": \"value1\", \"x-param2\": \"value2\" } ]")
fun testFromJsonStringWithParameters() {
val prop = UnknownProperty.fromJsonString("[ \"ATTENDEE\", \"PropValue\", { \"x-param1\": \"value1\", \"x-param2\": \"value2\" } ]")
assertTrue(prop is Attendee)
assertEquals("ATTENDEE", prop.name)
assertEquals("PropValue", prop.value)
......@@ -35,14 +35,14 @@ class UnknownPropertyTest {
@Test(expected = JSONException::class)
@SmallTest
fun testFromInvalidExtendedProperty() {
AndroidEvent.UnknownProperty.fromExtendedProperty("This isn't JSON")
fun testFromInvalidJsonString() {
UnknownProperty.fromJsonString("This isn't JSON")
}
@Test
@SmallTest
fun testToExtendedProperty() {
fun testToJsonString() {
val attendee = Attendee("mailto:test@test.at")
assertEquals(
"ATTENDEE:mailto:test@test.at",
......
......@@ -25,8 +25,6 @@ import net.fortuna.ical4j.model.component.VAlarm
import net.fortuna.ical4j.model.parameter.*
import net.fortuna.ical4j.model.property.*
import net.fortuna.ical4j.util.TimeZones
import org.json.JSONArray
import org.json.JSONObject
import java.io.ByteArrayInputStream
import java.io.FileNotFoundException
import java.io.ObjectInputStream
......@@ -52,13 +50,15 @@ abstract class AndroidEvent(
companion object {
/** [ExtendedProperties.NAME] for unknown iCal properties */
@Deprecated("New serialization format", ReplaceWith("EXT_UNKNOWN_PROPERTY2"))
const val EXT_UNKNOWN_PROPERTY = "unknown-property"
@Deprecated("New content item MIME type", ReplaceWith("UnknownProperty.CONTENT_ITEM_TYPE"))
const val EXT_UNKNOWN_PROPERTY2 = "unknown-property.v2"
const val MAX_UNKNOWN_PROPERTY_SIZE = 25000
// not declared in ical4j Parameters class yet
/**
* EMAIL parameter name (as used for ORGANIZER). Not declared in ical4j Parameters class yet.
*/
private const val PARAMETER_EMAIL = "EMAIL"
}
......@@ -329,17 +329,15 @@ abstract class AndroidEvent(
try {
when (row.getAsString(ExtendedProperties.NAME)) {
EXT_UNKNOWN_PROPERTY -> {
// deserialize unknown property v1 (deprecated)
// deserialize unknown property (deprecated format)
val stream = ByteArrayInputStream(Base64.decode(row.getAsString(ExtendedProperties.VALUE), Base64.NO_WRAP))
ObjectInputStream(stream).use {
event.unknownProperties += it.readObject() as Property
}
}
EXT_UNKNOWN_PROPERTY2 -> {
// deserialize unknown property v2
event.unknownProperties += UnknownProperty.fromExtendedProperty(row.getAsString(ExtendedProperties.VALUE))
}
EXT_UNKNOWN_PROPERTY2, UnknownProperty.CONTENT_ITEM_TYPE ->
event.unknownProperties += UnknownProperty.fromJsonString(row.getAsString(ExtendedProperties.VALUE))
}
} catch(e: Exception) {
Constants.log.log(Level.WARNING, "Couldn't parse extended property", e)
......@@ -694,14 +692,14 @@ abstract class AndroidEvent(
}
protected open fun insertUnknownProperty(batch: BatchOperation, idxEvent: Int, property: Property) {
if (property.value.length > MAX_UNKNOWN_PROPERTY_SIZE) {
if (property.value.length > UnknownProperty.MAX_UNKNOWN_PROPERTY_SIZE) {
Constants.log.warning("Ignoring unknown property with ${property.value.length} octets (too long)")
return
}
val builder = ContentProviderOperation.newInsert(calendar.syncAdapterURI(ExtendedProperties.CONTENT_URI))
builder .withValue(ExtendedProperties.NAME, EXT_UNKNOWN_PROPERTY2)
.withValue(ExtendedProperties.VALUE, UnknownProperty.toExtendedProperty(property))
.withValue(ExtendedProperties.NAME, UnknownProperty.CONTENT_ITEM_TYPE)
.withValue(ExtendedProperties.VALUE, UnknownProperty.toJsonString(property))
batch.enqueue(BatchOperation.Operation(builder, ExtendedProperties.EVENT_ID, idxEvent))
}
......@@ -735,62 +733,4 @@ abstract class AndroidEvent(
override fun toString() = MiscUtils.reflectionToString(this)
/**
* Helpers to (de)serialize unknown properties as JSON to store it in an Android ExtendedProperty row.
*
* Format: `{ propertyName, propertyValue, { param1Name: param1Value, ... } }`, with the third
* array (parameters) being optional.
*/
object UnknownProperty {
private val parameterFactory = ParameterFactoryRegistry()
private val propertyFactory = PropertyFactoryRegistry()
/**
* Deserializes a JSON string from an ExtendedProperty value to an ical4j property.
*
* @param jsonString JSON representation of an ical4j property
* @return ical4j property, generated from [jsonString]
* @throws org.json.JSONException when the input value can't be parsed
*/
fun fromExtendedProperty(jsonString: String): Property {
val json = JSONArray(jsonString)
val name = json.getString(0)
val value = json.getString(1)
val params = ParameterList()
json.optJSONObject(2)?.let { jsonParams ->
for (paramName in jsonParams.keys())
params.add(parameterFactory.createParameter(
paramName,
jsonParams.getString(paramName)
))
}
return propertyFactory.createProperty(name, params, value)
}
/**
* Serializes an ical4j property to a JSON string that can be stored in an ExtendedProperty.
*
* @param prop property to serialize as JSON
* @return JSON representation of [prop]
*/
fun toExtendedProperty(prop: Property): String {
val json = JSONArray()
json.put(prop.name)
json.put(prop.value)
if (!prop.parameters.isEmpty) {
val jsonParams = JSONObject()
for (param in prop.parameters)
jsonParams.put(param.name, param.value)
json.put(jsonParams)
}
return json.toString()
}
}
}
......@@ -46,6 +46,10 @@ abstract class AndroidTask(
val taskList: AndroidTaskList<AndroidTask>
) {
companion object {
const val UNKNOWN_PROPERTY_DATA = Properties.DATA0
}
var id: Long? = null
......@@ -84,11 +88,8 @@ abstract class AndroidTask(
client.query(taskList.tasksPropertiesSyncUri(), null,
"${Properties.TASK_ID}=?", arrayOf(id.toString()),
null)?.use { propCursor ->
while (propCursor.moveToNext()) {
val propValues = propCursor.toValues(true)
Constants.log.log(Level.FINER, "Found property", propValues)
populateProperty(propValues)
}
while (propCursor.moveToNext())
populateProperty(propCursor.toValues(true))
}
return task
......@@ -180,12 +181,16 @@ abstract class AndroidTask(
}
protected open fun populateProperty(row: ContentValues) {
Constants.log.log(Level.FINER, "Found property", row)
val task = requireNotNull(task)
when (val type = row.getAsString(Properties.MIMETYPE)) {
Alarm.CONTENT_ITEM_TYPE ->
populateAlarm(row)
Category.CONTENT_ITEM_TYPE ->
task.categories += row.getAsString(Category.CATEGORY_NAME)
UnknownProperty.CONTENT_ITEM_TYPE ->
task.unknownProperties += UnknownProperty.fromJsonString(row.getAsString(UNKNOWN_PROPERTY_DATA))
else ->
Constants.log.warning("Found unknown property of type $type")
}
......@@ -259,6 +264,7 @@ abstract class AndroidTask(
private fun insertProperties(batch: BatchOperation) {
insertAlarms(batch)
insertCategories(batch)
insertUnknownProperties(batch)
}
private fun insertAlarms(batch: BatchOperation) {
......@@ -282,7 +288,7 @@ abstract class AndroidTask(
}
val builder = ContentProviderOperation.newInsert(taskList.tasksPropertiesSyncUri())
builder .withValue(Alarm.TASK_ID, id)
.withValue(Alarm.TASK_ID, id)
.withValue(Alarm.MIMETYPE, Alarm.CONTENT_ITEM_TYPE)
.withValue(Alarm.MINUTES_BEFORE, ICalendar.alarmMinBefore(alarm))
.withValue(Alarm.REFERENCE, alarmRef)
......@@ -297,7 +303,7 @@ abstract class AndroidTask(
private fun insertCategories(batch: BatchOperation) {
for (category in requireNotNull(task).categories) {
val builder = ContentProviderOperation.newInsert(taskList.tasksPropertiesSyncUri())
builder .withValue(Category.TASK_ID, id)
.withValue(Category.TASK_ID, id)
.withValue(Category.MIMETYPE, Category.CONTENT_ITEM_TYPE)
.withValue(Category.CATEGORY_NAME, category)
Constants.log.log(Level.FINE, "Inserting category", builder.build())
......@@ -305,6 +311,22 @@ abstract class AndroidTask(
}
}
private 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)")
return
}
val builder = ContentProviderOperation.newInsert(taskList.tasksPropertiesSyncUri())
.withValue(Properties.TASK_ID, id)
.withValue(Properties.MIMETYPE, UnknownProperty.CONTENT_ITEM_TYPE)
.withValue(UNKNOWN_PROPERTY_DATA, UnknownProperty.toJsonString(property))
Constants.log.log(Level.FINE, "Inserting unknown property", builder.build())
batch.enqueue(BatchOperation.Operation(builder))
}
}
fun delete(): Int {
try {
return taskList.provider.client.delete(taskSyncURI(), null, null)
......@@ -318,16 +340,15 @@ abstract class AndroidTask(
builder .withValue(Tasks.LIST_ID, taskList.id)
val task = requireNotNull(task)
builder
.withValue(Tasks._UID, task.uid)
builder .withValue(Tasks._UID, task.uid)
.withValue(Tasks._DIRTY, 0)
.withValue(Tasks.SYNC_VERSION, task.sequence)
.withValue(Tasks.TITLE, task.summary)
.withValue(Tasks.LOCATION, task.location)
builder .withValue(Tasks.GEO, task.geoPosition?.value)
.withValue(Tasks.GEO, task.geoPosition?.value)
builder .withValue(Tasks.DESCRIPTION, task.description)
.withValue(Tasks.DESCRIPTION, task.description)
.withValue(Tasks.TASK_COLOR, task.color)
.withValue(Tasks.URL, task.url)
......@@ -379,14 +400,14 @@ abstract class AndroidTask(
builder .withValue(Tasks.CREATED, task.createdAt)
.withValue(Tasks.LAST_MODIFIED, task.lastModified)
builder .withValue(Tasks.DTSTART, task.dtStart?.date?.time)
.withValue(Tasks.DTSTART, task.dtStart?.date?.time)
.withValue(Tasks.DUE, task.due?.date?.time)
.withValue(Tasks.DURATION, task.duration?.value)
builder .withValue(Tasks.RDATE, if (task.rDates.isEmpty()) null else DateUtils.recurrenceSetsToAndroidString(task.rDates, allDay))
.withValue(Tasks.RDATE, if (task.rDates.isEmpty()) null else DateUtils.recurrenceSetsToAndroidString(task.rDates, allDay))
.withValue(Tasks.RRULE, task.rRule?.value)
builder .withValue(Tasks.EXDATE, if (task.exDates.isEmpty()) null else DateUtils.recurrenceSetsToAndroidString(task.exDates, allDay))
.withValue(Tasks.EXDATE, if (task.exDates.isEmpty()) null else DateUtils.recurrenceSetsToAndroidString(task.exDates, allDay))
Constants.log.log(Level.FINE, "Built task object", builder.build())
}
......
package at.bitfire.ical4android
import android.content.ContentResolver
import net.fortuna.ical4j.model.ParameterFactoryRegistry
import net.fortuna.ical4j.model.ParameterList
import net.fortuna.ical4j.model.Property
import net.fortuna.ical4j.model.PropertyFactoryRegistry
import org.json.JSONArray
import org.json.JSONObject
/**
* Helpers to (de)serialize unknown properties as JSON to store it in an Android ExtendedProperty row.
*
* Format: `{ propertyName, propertyValue, { param1Name: param1Value, ... } }`, with the third
* array (parameters) being optional.
*/
object UnknownProperty {
/**
* Use this value for [android.provider.CalendarContract.ExtendedProperties.NAME] and
* [org.dmfs.tasks.contract.TaskContract.Properties.MIMETYPE].
*/
const val CONTENT_ITEM_TYPE = ContentResolver.CURSOR_ITEM_BASE_TYPE + "/vnd.ical4android.unknown-property"
/**
* Recommended maximum size of properties for serialization. Won't be enforced by this
* class (should be checked by caller).
*/
const val MAX_UNKNOWN_PROPERTY_SIZE = 25000
private val parameterFactory = ParameterFactoryRegistry()
private val propertyFactory = PropertyFactoryRegistry()
/**
* Deserializes a JSON string from an ExtendedProperty value to an ical4j property.
*
* @param jsonString JSON representation of an ical4j property
* @return ical4j property, generated from [jsonString]
* @throws org.json.JSONException when the input value can't be parsed
*/
fun fromJsonString(jsonString: String): Property {
val json = JSONArray(jsonString)
val name = json.getString(0)
val value = json.getString(1)
val params = ParameterList()
json.optJSONObject(2)?.let { jsonParams ->
for (paramName in jsonParams.keys())
params.add(parameterFactory.createParameter(
paramName,
jsonParams.getString(paramName)
))
}
return propertyFactory.createProperty(name, params, value)
}
/**
* Serializes an ical4j property to a JSON string that can be stored in an ExtendedProperty.
*
* @param prop property to serialize as JSON
* @return JSON representation of [prop]
*/
fun toJsonString(prop: Property): String {
val json = JSONArray()
json.put(prop.name)
json.put(prop.value)
if (!prop.parameters.isEmpty) {
val jsonParams = JSONObject()
for (param in prop.parameters)
jsonParams.put(param.name, param.value)
json.put(jsonParams)
}
return json.toString()
}
}
\ No newline at end of file
......@@ -71,7 +71,7 @@ class EventTest {
}
@Test
fun testParseAndWrite() {
fun testParse() {
val event = parseCalendar("utf8.ics").first()
assertEquals("utf8@ical4android.EventTest", event.uid)
assertEquals("© äö — üß", event.summary)
......@@ -79,6 +79,11 @@ class EventTest {
assertEquals("中华人民共和国", event.location)
assertEquals(Css3Color.aliceblue, event.color)
assertEquals("cyrus@example.com", event.attendees.first.parameters.getParameter("EMAIL").value)
val unknown = event.unknownProperties.first
assertEquals("X-UNKNOWN-PROP", unknown.name)
assertEquals("xxx", unknown.getParameter("param1").value)
assertEquals("Unknown Value", unknown.value)
}
@Test
......
......@@ -87,8 +87,15 @@ class TaskTest {
assertEquals(828106200000L, t.createdAt)
assertEquals(840288600000L, t.lastModified)
assertTrue(t.unknownProperties.isEmpty())
assertArrayEquals(arrayOf("Test","Sample"), t.categories.toArray())
val unknown = t.unknownProperties.first
assertEquals("X-UNKNOWN-PROP", unknown.name)
assertEquals("xxx", unknown.getParameter("param1").value)
assertEquals("Unknown Value", unknown.value)
// other file
t = regenerate(parseCalendar("most-fields2.ics"))
assertEquals("most-fields2@example.com", t.uid)
assertEquals(DtStart(DateTime("20100101T101010Z")), t.dtStart)
......
......@@ -8,6 +8,7 @@ DESCRIPTION:Test Description
LOCATION:中华人民共和国
COLOR:aliceblue
ATTENDEE;CN=Cyrus Daboo;EMAIL=cyrus@example.com:mailto:opaque-token-1234@example.com
X-UNKNOWN-PROP;param1=xxx:Unknown Value
DTSTART:20131009T170000T
DTEND:20131009T180000T
END:VEVENT
......
......@@ -18,11 +18,13 @@ STATUS:IN-PROCESS
PERCENT-COMPLETE:25
DTSTART;VALUE=DATE:20100101
DUE;VALUE=DATE:20101001
CATEGORIES:Test,Sample
RRULE:FREQ=YEARLY;INTERVAL=2
EXDATE;VALUE=DATE:20120101
EXDATE;VALUE=DATE:20140101,20180101
RDATE;VALUE=DATE:20100310,20100315
RDATE;VALUE=DATE:20100810
X-UNKNOWN-PROP;param1=xxx:Unknown Value
CREATED:19960329T133000Z
LAST-MODIFIED:19960817T133000Z
END:VTODO
......
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