diff --git a/lib/src/androidTest/kotlin/at/bitfire/ical4android/DmfsTaskTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/DmfsTaskTest.kt index 1012fba8..a6d3fa5d 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/ical4android/DmfsTaskTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/DmfsTaskTest.kt @@ -8,52 +8,29 @@ package at.bitfire.ical4android import android.accounts.Account import android.content.ContentUris -import android.content.ContentValues -import android.database.DatabaseUtils import android.net.Uri import androidx.core.content.contentValuesOf import at.bitfire.ical4android.impl.TestTaskList import at.bitfire.synctools.storage.LocalStorageException import at.bitfire.synctools.storage.tasks.DmfsTaskList import net.fortuna.ical4j.model.Date -import net.fortuna.ical4j.model.DateList -import net.fortuna.ical4j.model.DateTime import net.fortuna.ical4j.model.TimeZoneRegistryFactory import net.fortuna.ical4j.model.component.VAlarm -import net.fortuna.ical4j.model.parameter.Email import net.fortuna.ical4j.model.parameter.RelType -import net.fortuna.ical4j.model.parameter.TzId -import net.fortuna.ical4j.model.parameter.Value -import net.fortuna.ical4j.model.parameter.XParameter -import net.fortuna.ical4j.model.property.Clazz -import net.fortuna.ical4j.model.property.Completed import net.fortuna.ical4j.model.property.DtStart import net.fortuna.ical4j.model.property.Due import net.fortuna.ical4j.model.property.Duration -import net.fortuna.ical4j.model.property.ExDate -import net.fortuna.ical4j.model.property.Geo import net.fortuna.ical4j.model.property.Organizer -import net.fortuna.ical4j.model.property.RDate -import net.fortuna.ical4j.model.property.RRule import net.fortuna.ical4j.model.property.RelatedTo -import net.fortuna.ical4j.model.property.Status import net.fortuna.ical4j.model.property.XProperty import org.dmfs.tasks.contract.TaskContract -import org.dmfs.tasks.contract.TaskContract.Properties -import org.dmfs.tasks.contract.TaskContract.Property -import org.dmfs.tasks.contract.TaskContract.Property.Category -import org.dmfs.tasks.contract.TaskContract.Property.Relation -import org.dmfs.tasks.contract.TaskContract.PropertyColumns import org.dmfs.tasks.contract.TaskContract.Tasks import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test -import java.time.ZoneId class DmfsTaskTest( providerName: TaskProvider.ProviderName @@ -61,8 +38,6 @@ class DmfsTaskTest( private val tzRegistry = TimeZoneRegistryFactory.getInstance().createRegistry()!! private val tzVienna = tzRegistry.getTimeZone("Europe/Vienna")!! - private val tzChicago = tzRegistry.getTimeZone("America/Chicago")!! - private val tzDefault = tzRegistry.getTimeZone(ZoneId.systemDefault().id)!! private val testAccount = Account(javaClass.name, TaskContract.LOCAL_ACCOUNT_TYPE) @@ -88,19 +63,6 @@ class DmfsTaskTest( // tests - private fun buildTask(taskBuilder: Task.() -> Unit): ContentValues { - val task = Task().apply { - taskBuilder() - } - val uri = DmfsTask(taskList!!, task, "9468a4cf-0d5b-4379-a704-12f1f84100ba", null, 0).add() - provider.client.query(uri, null, null, null, null)!!.use { - it.moveToNext() - val values = ContentValues() - DatabaseUtils.cursorRowToContentValues(it, values) - return values - } - } - @Test fun testConstructor_ContentValues() { val dmfsTask = DmfsTask( @@ -117,553 +79,6 @@ class DmfsTaskTest( assertEquals(45, dmfsTask.flags) } - @Test - fun testBuildTask_Sequence() { - buildTask { - sequence = 12345 - }.let { result -> - assertEquals(12345, result.getAsInteger(Tasks.SYNC_VERSION)) - } - } - - @Test - fun testBuildTask_CreatedAt() { - buildTask { - createdAt = 1593771404 // Fri Jul 03 10:16:44 2020 UTC - }.let { result -> - assertEquals(1593771404, result.getAsLong(Tasks.CREATED)) - } - } - - @Test - fun testBuildTask_LastModified() { - buildTask { - lastModified = 1593771404 - }.let { result -> - assertEquals(1593771404, result.getAsLong(Tasks.LAST_MODIFIED)) - } - } - - @Test - fun testBuildTask_Summary() { - buildTask { - summary = "Sample Summary" - }.let { result -> - assertEquals("Sample Summary", result.get(Tasks.TITLE)) - } - } - - @Test - fun testBuildTask_Location() { - buildTask { - location = "Sample Location" - }.let { result -> - assertEquals("Sample Location", result.get(Tasks.LOCATION)) - } - } - - @Test - fun testBuildTask_Geo() { - buildTask { - geoPosition = Geo(47.913563.toBigDecimal(), 16.159601.toBigDecimal()) - }.let { result -> - assertEquals("16.159601,47.913563", result.get(Tasks.GEO)) - } - } - - @Test - fun testBuildTask_Description() { - buildTask { - description = "Sample Description" - }.let { result -> - assertEquals("Sample Description", result.get(Tasks.DESCRIPTION)) - } - } - - @Test - fun testBuildTask_Color() { - buildTask { - color = 0x11223344 - }.let { result -> - assertEquals(0x11223344, result.getAsInteger(Tasks.TASK_COLOR)) - } - } - - @Test - fun testBuildTask_Url() { - buildTask { - url = "https://www.example.com" - }.let { result -> - assertEquals("https://www.example.com", result.getAsString(Tasks.URL)) - } - } - - @Test - fun testBuildTask_Organizer_MailTo() { - buildTask { - organizer = Organizer("mailto:organizer@example.com") - }.let { result -> - assertEquals("organizer@example.com", result.getAsString(Tasks.ORGANIZER)) - } - } - - @Test - fun testBuildTask_Organizer_EmailParameter() { - buildTask { - organizer = Organizer("uri:unknown").apply { - parameters.add(Email("organizer@example.com")) - } - }.let { result -> - assertEquals("organizer@example.com", result.getAsString(Tasks.ORGANIZER)) - } - } - - @Test - fun testBuildTask_Organizer_NotEmail() { - buildTask { - organizer = Organizer("uri:unknown") - }.let { result -> - assertNull(result.get(Tasks.ORGANIZER)) - } - } - - @Test - fun testBuildTask_Priority() { - buildTask { - priority = 2 - }.let { result -> - assertEquals(2, result.getAsInteger(Tasks.PRIORITY)) - } - } - - @Test - fun testBuildTask_Classification_Public() { - buildTask { - classification = Clazz.PUBLIC - }.let { result -> - assertEquals(Tasks.CLASSIFICATION_PUBLIC, result.getAsInteger(Tasks.CLASSIFICATION)) - } - } - - @Test - fun testBuildTask_Classification_Private() { - buildTask { - classification = Clazz.PRIVATE - }.let { result -> - assertEquals(Tasks.CLASSIFICATION_PRIVATE, result.getAsInteger(Tasks.CLASSIFICATION)) - } - } - - @Test - fun testBuildTask_Classification_Confidential() { - buildTask { - classification = Clazz.CONFIDENTIAL - }.let { result -> - assertEquals(Tasks.CLASSIFICATION_CONFIDENTIAL, result.getAsInteger(Tasks.CLASSIFICATION)) - } - } - - @Test - fun testBuildTask_Classification_Custom() { - buildTask { - classification = Clazz("x-custom") - }.let { result -> - assertEquals(Tasks.CLASSIFICATION_PRIVATE, result.getAsInteger(Tasks.CLASSIFICATION)) - } - } - - @Test - fun testBuildTask_Classification_None() { - buildTask { - }.let { result -> - assertEquals(Tasks.CLASSIFICATION_DEFAULT /* null */, result.getAsInteger(Tasks.CLASSIFICATION)) - } - } - - @Test - fun testBuildTask_Status_NeedsAction() { - buildTask { - status = Status.VTODO_NEEDS_ACTION - }.let { result -> - assertEquals(Tasks.STATUS_NEEDS_ACTION, result.getAsInteger(Tasks.STATUS)) - } - } - - @Test - fun testBuildTask_Status_Completed() { - buildTask { - status = Status.VTODO_COMPLETED - }.let { result -> - assertEquals(Tasks.STATUS_COMPLETED, result.getAsInteger(Tasks.STATUS)) - } - } - - @Test - fun testBuildTask_Status_InProcess() { - buildTask { - status = Status.VTODO_IN_PROCESS - }.let { result -> - assertEquals(Tasks.STATUS_IN_PROCESS, result.getAsInteger(Tasks.STATUS)) - } - } - - @Test - fun testBuildTask_Status_Cancelled() { - buildTask { - status = Status.VTODO_CANCELLED - }.let { result -> - assertEquals(Tasks.STATUS_CANCELLED, result.getAsInteger(Tasks.STATUS)) - } - } - - @Test - fun testBuildTask_DtStart() { - buildTask { - dtStart = DtStart("20200703T155722", tzVienna) - }.let { result -> - assertEquals(1593784642000L, result.getAsLong(Tasks.DTSTART)) - assertEquals(tzVienna.id, result.getAsString(Tasks.TZ)) - assertEquals(0, result.getAsInteger(Tasks.IS_ALLDAY)) - } - } - - @Test - fun testBuildTask_DtStart_AllDay() { - buildTask { - dtStart = DtStart(Date("20200703")) - }.let { result -> - assertEquals(1593734400000L, result.getAsLong(Tasks.DTSTART)) - assertNull(result.get(Tasks.TZ)) - assertEquals(1, result.getAsInteger(Tasks.IS_ALLDAY)) - } - } - - @Test - fun testBuildTask_Due() { - buildTask { - due = Due(DateTime("20200703T155722", tzVienna)) - }.let { result -> - assertEquals(1593784642000L, result.getAsLong(Tasks.DUE)) - assertEquals(tzVienna.id, result.getAsString(Tasks.TZ)) - assertEquals(0, result.getAsInteger(Tasks.IS_ALLDAY)) - } - } - - @Test - fun testBuildTask_Due_AllDay() { - buildTask { - due = Due(Date("20200703")) - }.let { result -> - assertEquals(1593734400000L, result.getAsLong(Tasks.DUE)) - assertNull(result.getAsString(Tasks.TZ)) - assertEquals(1, result.getAsInteger(Tasks.IS_ALLDAY)) - } - } - - @Test - fun testBuildTask_DtStart_NonAllDay_Due_AllDay() { - buildTask { - dtStart = DtStart(DateTime("20200101T010203")) - due = Due(Date("20200201")) - }.let { result -> - assertEquals(ZoneId.systemDefault().id, result.getAsString(Tasks.TZ)) - assertEquals(0, result.getAsInteger(Tasks.IS_ALLDAY)) - } - } - - @Test - fun testBuildTask_DtStart_AllDay_Due_NonAllDay() { - buildTask { - dtStart = DtStart(Date("20200101")) - due = Due(DateTime("20200201T010203")) - }.let { result -> - assertNull(result.getAsString(Tasks.TZ)) - assertEquals(1, result.getAsInteger(Tasks.IS_ALLDAY)) - } - } - - @Test - fun testBuildTask_DtStart_AllDay_Due_AllDay() { - buildTask { - dtStart = DtStart(Date("20200101")) - due = Due(Date("20200201")) - }.let { result -> - assertEquals(1, result.getAsInteger(Tasks.IS_ALLDAY)) - } - } - - @Test - fun testBuildTask_DtStart_FloatingTime() { - buildTask { - dtStart = DtStart("20200703T010203") - }.let { result -> - assertEquals(DateTime("20200703T010203").time, result.getAsLong(Tasks.DTSTART)) - assertEquals(ZoneId.systemDefault().id, result.getAsString(Tasks.TZ)) - assertEquals(0, result.getAsInteger(Tasks.IS_ALLDAY)) - } - } - - @Test - fun testBuildTask_DtStart_Utc() { - buildTask { - dtStart = DtStart(DateTime(1593730923000), true) - }.let { result -> - assertEquals(1593730923000L, result.getAsLong(Tasks.DTSTART)) - assertEquals("Etc/UTC", result.getAsString(Tasks.TZ)) - assertEquals(0, result.getAsInteger(Tasks.IS_ALLDAY)) - } - } - - @Test - fun testBuildTask_Due_FloatingTime() { - buildTask { - due = Due("20200703T010203") - }.let { result -> - assertEquals(DateTime("20200703T010203").time, result.getAsLong(Tasks.DUE)) - assertEquals(ZoneId.systemDefault().id, result.getAsString(Tasks.TZ)) - assertEquals(0, result.getAsInteger(Tasks.IS_ALLDAY)) - } - } - - @Test - fun testBuildTask_Due_Utc() { - buildTask { - due = Due(DateTime(1593730923000).apply { isUtc = true }) - }.let { result -> - assertEquals(1593730923000L, result.getAsLong(Tasks.DUE)) - assertEquals("Etc/UTC", result.getAsString(Tasks.TZ)) - assertEquals(0, result.getAsInteger(Tasks.IS_ALLDAY)) - } - } - - @Test - fun testBuildTask_Duration() { - buildTask { - dtStart = DtStart(DateTime()) - duration = Duration(null, "P1D") - }.let { result -> - assertEquals("P1D", result.get(Tasks.DURATION)) - } - } - - @Test - fun testBuildTask_CompletedAt() { - val now = DateTime() - buildTask { - completedAt = Completed(now) - }.let { result -> - // Note: iCalendar does not allow COMPLETED to be all-day [RFC 5545 3.8.2.1] - assertEquals(0, result.getAsInteger(Tasks.COMPLETED_IS_ALLDAY)) - assertEquals(now.time, result.getAsLong(Tasks.COMPLETED)) - } - } - - @Test - fun testBuildTask_PercentComplete() { - buildTask { - percentComplete = 50 - }.let { result -> - assertEquals(50, result.getAsInteger(Tasks.PERCENT_COMPLETE)) - } - } - - @Test - fun testBuildTask_RRule() { - // Note: OpenTasks only supports one RRULE per VTODO (iCalendar: multiple RRULEs are allowed, but SHOULD not be used) - buildTask { - rRule = RRule("FREQ=DAILY;COUNT=10") - }.let { result -> - assertEquals("FREQ=DAILY;COUNT=10", result.getAsString(Tasks.RRULE)) - } - } - - @Test - fun testBuildTask_RDate() { - buildTask { - dtStart = DtStart(DateTime("20200101T010203", tzVienna)) - rDates += RDate(DateList("20200102T020304", Value.DATE_TIME, tzVienna)) - rDates += RDate(DateList("20200102T020304", Value.DATE_TIME, tzChicago)) - rDates += RDate(DateList("20200103T020304Z", Value.DATE_TIME)) - rDates += RDate(DateList("20200103", Value.DATE)) - }.let { result -> - assertEquals(tzVienna.id, result.getAsString(Tasks.TZ)) - assertEquals("20200102T020304,20200102T090304,20200103T020304Z,20200103T000000", result.getAsString(Tasks.RDATE)) - } - } - - @Test - fun testBuildTask_ExDate() { - buildTask { - dtStart = DtStart(DateTime("20200101T010203", tzVienna)) - rRule = RRule("FREQ=DAILY;COUNT=10") - exDates += ExDate(DateList("20200102T020304", Value.DATE_TIME, tzVienna)) - exDates += ExDate(DateList("20200102T020304", Value.DATE_TIME, tzChicago)) - exDates += ExDate(DateList("20200103T020304Z", Value.DATE_TIME)) - exDates += ExDate(DateList("20200103", Value.DATE)) - }.let { result -> - assertEquals(tzVienna.id, result.getAsString(Tasks.TZ)) - assertEquals("20200102T020304,20200102T090304,20200103T020304Z,20200103T000000", result.getAsString(Tasks.EXDATE)) - } - } - - @Test - fun testBuildTask_Categories() { - var hasCat1 = false - var hasCat2 = false - buildTask { - categories.addAll(arrayOf("Cat_1", "Cat 2")) - }.let { result -> - val id = result.getAsLong(Tasks._ID) - val uri = taskList!!.tasksPropertiesUri() - provider.client.query(uri, arrayOf(Category.CATEGORY_NAME), "${Properties.MIMETYPE}=? AND ${PropertyColumns.TASK_ID}=?", - arrayOf(Category.CONTENT_ITEM_TYPE, id.toString()), null)!!.use { cursor -> - while (cursor.moveToNext()) - when (cursor.getString(0)) { - "Cat_1" -> hasCat1 = true - "Cat 2" -> hasCat2 = true - } - } - } - assertTrue(hasCat1) - assertTrue(hasCat2) - } - - @Test - fun testBuildTask_Comment() { - var hasComment = false - buildTask { - comment = "Comment value" - }.let { result -> - val id = result.getAsLong(Tasks._ID) - val uri = taskList!!.tasksPropertiesUri() - provider.client.query(uri, arrayOf(Property.Comment.COMMENT), "${Properties.MIMETYPE}=? AND ${PropertyColumns.TASK_ID}=?", - arrayOf(Property.Comment.CONTENT_ITEM_TYPE, id.toString()), null)!!.use { cursor -> - if (cursor.moveToNext()) - hasComment = cursor.getString(0) == "Comment value" - } - } - assertTrue(hasComment) - } - - @Test - fun testBuildTask_Comment_empty() { - var hasComment: Boolean - buildTask { - comment = null - }.let { result -> - val id = result.getAsLong(Tasks._ID) - val uri = taskList!!.tasksPropertiesUri() - provider.client.query(uri, arrayOf(Property.Comment.COMMENT), "${Properties.MIMETYPE}=? AND ${PropertyColumns.TASK_ID}=?", - arrayOf(Property.Comment.CONTENT_ITEM_TYPE, id.toString()), null)!!.use { cursor -> - hasComment = cursor.count > 0 - } - } - assertFalse(hasComment) - } - - private fun firstProperty(taskId: Long, mimeType: String): ContentValues? { - val uri = taskList!!.tasksPropertiesUri() - provider.client.query(uri, null, "${Properties.MIMETYPE}=? AND ${PropertyColumns.TASK_ID}=?", - arrayOf(mimeType, taskId.toString()), null)!!.use { cursor -> - if (cursor.moveToNext()) { - val result = ContentValues(cursor.count) - DatabaseUtils.cursorRowToContentValues(cursor, result) - return result - } - } - return null - } - - @Test - fun testBuildTask_RelatedTo_Parent() { - buildTask { - relatedTo.add(RelatedTo("Parent-Task").apply { - parameters.add(RelType.PARENT) - }) - }.let { result -> - val taskId = result.getAsLong(Tasks._ID) - val relation = firstProperty(taskId, Relation.CONTENT_ITEM_TYPE)!! - assertEquals("Parent-Task", relation.getAsString(Relation.RELATED_UID)) - assertNull(relation.get(Relation.RELATED_ID)) // other task not in DB (yet) - assertEquals(Relation.RELTYPE_PARENT, relation.getAsInteger(Relation.RELATED_TYPE)) - } - } - - @Test - fun testBuildTask_RelatedTo_Child() { - buildTask { - relatedTo.add(RelatedTo("Child-Task").apply { - parameters.add(RelType.CHILD) - }) - }.let { result -> - val taskId = result.getAsLong(Tasks._ID) - val relation = firstProperty(taskId, Relation.CONTENT_ITEM_TYPE)!! - assertEquals("Child-Task", relation.getAsString(Relation.RELATED_UID)) - assertNull(relation.get(Relation.RELATED_ID)) // other task not in DB (yet) - assertEquals(Relation.RELTYPE_CHILD, relation.getAsInteger(Relation.RELATED_TYPE)) - } - } - - @Test - fun testBuildTask_RelatedTo_Sibling() { - buildTask { - relatedTo.add(RelatedTo("Sibling-Task").apply { - parameters.add(RelType.SIBLING) - }) - }.let { result -> - val taskId = result.getAsLong(Tasks._ID) - val relation = firstProperty(taskId, Relation.CONTENT_ITEM_TYPE)!! - assertEquals("Sibling-Task", relation.getAsString(Relation.RELATED_UID)) - assertNull(relation.get(Relation.RELATED_ID)) // other task not in DB (yet) - assertEquals(Relation.RELTYPE_SIBLING, relation.getAsInteger(Relation.RELATED_TYPE)) - } - } - - @Test - fun testBuildTask_RelatedTo_Custom() { - buildTask { - relatedTo.add(RelatedTo("Sibling-Task").apply { - parameters.add(RelType("custom-relationship")) - }) - }.let { result -> - val taskId = result.getAsLong(Tasks._ID) - val relation = firstProperty(taskId, Relation.CONTENT_ITEM_TYPE)!! - assertEquals("Sibling-Task", relation.getAsString(Relation.RELATED_UID)) - assertNull(relation.get(Relation.RELATED_ID)) // other task not in DB (yet) - assertEquals(Relation.RELTYPE_PARENT, relation.getAsInteger(Relation.RELATED_TYPE)) - } - } - - @Test - fun testBuildTask_RelatedTo_Default() { - buildTask { - relatedTo.add(RelatedTo("Parent-Task")) - }.let { result -> - val taskId = result.getAsLong(Tasks._ID) - val relation = firstProperty(taskId, Relation.CONTENT_ITEM_TYPE)!! - assertEquals("Parent-Task", relation.getAsString(Relation.RELATED_UID)) - assertNull(relation.get(Relation.RELATED_ID)) // other task not in DB (yet) - assertEquals(Relation.RELTYPE_PARENT, relation.getAsInteger(Relation.RELATED_TYPE)) - } - } - - - @Test - fun testBuildTask_UnknownProperty() { - val xProperty = XProperty("X-TEST-PROPERTY", "test-value").apply { - parameters.add(TzId(tzVienna.id)) - parameters.add(XParameter("X-TEST-PARAMETER", "12345")) - } - buildTask { - unknownProperties.add(xProperty) - }.let { result -> - val taskId = result.getAsLong(Tasks._ID) - val unknownProperty = firstProperty(taskId, UnknownProperty.CONTENT_ITEM_TYPE)!! - assertEquals(xProperty, UnknownProperty.fromJsonString(unknownProperty.getAsString(DmfsTask.UNKNOWN_PROPERTY_DATA))) - } - } - - @Test fun testAddTask() { // build and write event to calendar provider @@ -772,49 +187,4 @@ class DmfsTaskTest( } } - @Test - fun testBuildAllDayTask() { - // add all-day event to calendar provider - val task = Task() - task.summary = "All-day task" - task.description = "All-day task for testing" - task.location = "Sample location testBuildAllDayTask" - task.dtStart = DtStart(Date("20150501")) - task.due = Due(Date("20150502")) - assertTrue(task.isAllDay()) - val uri = DmfsTask(taskList!!, task, "9468a4cf-0d5b-4379-a704-12f1f84100ba", null, 0).add() - assertNotNull(uri) - - val testTask = taskList!!.getTask(ContentUris.parseId(uri)) - try { - // read again and verify result - val task2 = testTask.task!! - assertEquals(task.summary, task2.summary) - assertEquals(task.description, task2.description) - assertEquals(task.location, task2.location) - assertEquals(task.dtStart!!.date, task2.dtStart!!.date) - assertEquals(task.due!!.date, task2.due!!.date) - assertTrue(task2.isAllDay()) - } finally { - testTask.delete() - } - } - - @Test - fun testGetTimeZone() { - // no date/time - var t = DmfsTask(taskList!!, Task(), "9468a4cf-0d5b-4379-a704-12f1f84100ba", null, 0) - assertEquals(tzDefault, t.getTimeZone()) - - // dtstart with date (no time) - t = DmfsTask(taskList!!, Task(), "410c19d7-df79-4d65-8146-40b7bec5923b", null, 0) - t.task!!.dtStart = DtStart("20150101") - assertEquals(tzDefault, t.getTimeZone()) - - // dtstart with time - t = DmfsTask(taskList!!, Task(), "9dc64544-1816-4f04-b952-e894164467f6", null, 0) - t.task!!.dtStart = (DtStart("20150101", tzVienna)) - assertEquals(tzVienna, t.getTimeZone()) - } - } \ No newline at end of file diff --git a/lib/src/androidTest/kotlin/at/bitfire/ical4android/impl/TestTaskList.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/impl/TestTaskList.kt index c219a9f2..20ea13ff 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/ical4android/impl/TestTaskList.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/impl/TestTaskList.kt @@ -26,7 +26,7 @@ object TestTaskList { values.put(TaskContract.TaskListColumns.VISIBLE, 1) val dmfsTaskListProvider = DmfsTaskListProvider(account, provider.client, provider.name) - return DmfsTaskList(dmfsTaskListProvider, values, provider.name) + return dmfsTaskListProvider.createAndGetTaskList(values) } } 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 new file mode 100644 index 00000000..8e467979 --- /dev/null +++ b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/tasks/DmfsTaskBuilderTest.kt @@ -0,0 +1,793 @@ +/* + * 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.mapping.tasks + +import android.accounts.Account +import android.content.ContentUris +import android.content.ContentValues +import android.database.DatabaseUtils +import android.net.Uri +import at.bitfire.ical4android.DmfsStyleProvidersTaskTest +import at.bitfire.ical4android.DmfsTask +import at.bitfire.ical4android.ICalendar +import at.bitfire.ical4android.Task +import at.bitfire.ical4android.TaskProvider +import at.bitfire.ical4android.UnknownProperty +import at.bitfire.ical4android.impl.TestTaskList +import at.bitfire.synctools.storage.tasks.DmfsTaskList +import net.fortuna.ical4j.model.Date +import net.fortuna.ical4j.model.DateList +import net.fortuna.ical4j.model.DateTime +import net.fortuna.ical4j.model.TimeZoneRegistryFactory +import net.fortuna.ical4j.model.parameter.Email +import net.fortuna.ical4j.model.parameter.RelType +import net.fortuna.ical4j.model.parameter.TzId +import net.fortuna.ical4j.model.parameter.Value +import net.fortuna.ical4j.model.parameter.XParameter +import net.fortuna.ical4j.model.property.Clazz +import net.fortuna.ical4j.model.property.Completed +import net.fortuna.ical4j.model.property.DtStart +import net.fortuna.ical4j.model.property.Due +import net.fortuna.ical4j.model.property.Duration +import net.fortuna.ical4j.model.property.ExDate +import net.fortuna.ical4j.model.property.Geo +import net.fortuna.ical4j.model.property.Organizer +import net.fortuna.ical4j.model.property.RDate +import net.fortuna.ical4j.model.property.RRule +import net.fortuna.ical4j.model.property.RelatedTo +import net.fortuna.ical4j.model.property.Status +import net.fortuna.ical4j.model.property.XProperty +import org.dmfs.tasks.contract.TaskContract +import org.junit.After +import org.junit.Assert +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import java.time.ZoneId + +class DmfsTaskBuilderTest ( + providerName: TaskProvider.ProviderName +): DmfsStyleProvidersTaskTest(providerName) { + + private val tzRegistry = TimeZoneRegistryFactory.getInstance().createRegistry()!! + private val tzVienna = tzRegistry.getTimeZone("Europe/Vienna")!! + private val tzChicago = tzRegistry.getTimeZone("America/Chicago")!! + private val tzDefault = tzRegistry.getTimeZone(ZoneId.systemDefault().id)!! + + private val testAccount = Account(javaClass.name, TaskContract.LOCAL_ACCOUNT_TYPE) + + private lateinit var taskListUri: Uri + private var taskList: DmfsTaskList? = null + + @Before + override fun prepare() { + super.prepare() + + taskList = TestTaskList.create(testAccount, provider) + Assert.assertNotNull("Couldn't find/create test task list", taskList) + + taskListUri = ContentUris.withAppendedId(provider.taskListsUri(), taskList!!.id) + } + + @After + override fun shutdown() { + taskList?.delete() + super.shutdown() + } + + + // builder tests + + @Test + fun testBuildTask_Sequence() { + buildTask { + ICalendar.apply { sequence = 12345 } + }.let { result -> + assertEquals(12345, result.getAsInteger(TaskContract.Tasks.SYNC_VERSION)) + } + } + + @Test + fun testBuildTask_CreatedAt() { + buildTask { + createdAt = 1593771404 // Fri Jul 03 10:16:44 2020 UTC + }.let { result -> + Assert.assertEquals(1593771404, result.getAsLong(TaskContract.Tasks.CREATED)) + } + } + + @Test + fun testBuildTask_LastModified() { + buildTask { + lastModified = 1593771404 + }.let { result -> + Assert.assertEquals(1593771404, result.getAsLong(TaskContract.Tasks.LAST_MODIFIED)) + } + } + + @Test + fun testBuildTask_Summary() { + buildTask { + summary = "Sample Summary" + }.let { result -> + assertEquals("Sample Summary", result.get(TaskContract.Tasks.TITLE)) + } + } + + @Test + fun testBuildTask_Location() { + buildTask { + location = "Sample Location" + }.let { result -> + assertEquals("Sample Location", result.get(TaskContract.Tasks.LOCATION)) + } + } + + @Test + fun testBuildTask_Geo() { + buildTask { + geoPosition = Geo(47.913563.toBigDecimal(), 16.159601.toBigDecimal()) + }.let { result -> + assertEquals("16.159601,47.913563", result.get(TaskContract.Tasks.GEO)) + } + } + + @Test + fun testBuildTask_Description() { + buildTask { + description = "Sample Description" + }.let { result -> + assertEquals("Sample Description", result.get(TaskContract.Tasks.DESCRIPTION)) + } + } + + @Test + fun testBuildTask_Color() { + buildTask { + color = 0x11223344 + }.let { result -> + assertEquals(0x11223344, result.getAsInteger(TaskContract.Tasks.TASK_COLOR)) + } + } + + @Test + fun testBuildTask_Url() { + buildTask { + url = "https://www.example.com" + }.let { result -> + assertEquals( + "https://www.example.com", + result.getAsString(TaskContract.Tasks.URL) + ) + } + } + + @Test + fun testBuildTask_Organizer_MailTo() { + buildTask { + organizer = Organizer("mailto:organizer@example.com") + }.let { result -> + assertEquals( + "organizer@example.com", + result.getAsString(TaskContract.Tasks.ORGANIZER) + ) + } + } + + @Test + fun testBuildTask_Organizer_EmailParameter() { + buildTask { + organizer = Organizer("uri:unknown").apply { + parameters.add(Email("organizer@example.com")) + } + }.let { result -> + assertEquals( + "organizer@example.com", + result.getAsString(TaskContract.Tasks.ORGANIZER) + ) + } + } + + @Test + fun testBuildTask_Organizer_NotEmail() { + buildTask { + organizer = Organizer("uri:unknown") + }.let { result -> + Assert.assertNull(result.get(TaskContract.Tasks.ORGANIZER)) + } + } + + @Test + fun testBuildTask_Priority() { + buildTask { + priority = 2 + }.let { result -> + assertEquals(2, result.getAsInteger(TaskContract.Tasks.PRIORITY)) + } + } + + @Test + fun testBuildTask_Classification_Public() { + buildTask { + classification = Clazz.PUBLIC + }.let { result -> + assertEquals( + TaskContract.Tasks.CLASSIFICATION_PUBLIC, + result.getAsInteger(TaskContract.Tasks.CLASSIFICATION) + ) + } + } + + @Test + fun testBuildTask_Classification_Private() { + buildTask { + classification = Clazz.PRIVATE + }.let { result -> + assertEquals( + TaskContract.Tasks.CLASSIFICATION_PRIVATE, + result.getAsInteger(TaskContract.Tasks.CLASSIFICATION) + ) + } + } + + @Test + fun testBuildTask_Classification_Confidential() { + buildTask { + classification = Clazz.CONFIDENTIAL + }.let { result -> + assertEquals( + TaskContract.Tasks.CLASSIFICATION_CONFIDENTIAL, + result.getAsInteger(TaskContract.Tasks.CLASSIFICATION) + ) + } + } + + @Test + fun testBuildTask_Classification_Custom() { + buildTask { + classification = Clazz("x-custom") + }.let { result -> + assertEquals( + TaskContract.Tasks.CLASSIFICATION_PRIVATE, + result.getAsInteger(TaskContract.Tasks.CLASSIFICATION) + ) + } + } + + @Test + fun testBuildTask_Classification_None() { + buildTask { + }.let { result -> + assertEquals( + TaskContract.Tasks.CLASSIFICATION_DEFAULT /* null */, + result.getAsInteger(TaskContract.Tasks.CLASSIFICATION) + ) + } + } + + @Test + fun testBuildTask_Status_NeedsAction() { + buildTask { + status = Status.VTODO_NEEDS_ACTION + }.let { result -> + assertEquals( + TaskContract.Tasks.STATUS_NEEDS_ACTION, + result.getAsInteger(TaskContract.Tasks.STATUS) + ) + } + } + + @Test + fun testBuildTask_Status_Completed() { + buildTask { + status = Status.VTODO_COMPLETED + }.let { result -> + assertEquals( + TaskContract.Tasks.STATUS_COMPLETED, + result.getAsInteger(TaskContract.Tasks.STATUS) + ) + } + } + + @Test + fun testBuildTask_Status_InProcess() { + buildTask { + status = Status.VTODO_IN_PROCESS + }.let { result -> + assertEquals( + TaskContract.Tasks.STATUS_IN_PROCESS, + result.getAsInteger(TaskContract.Tasks.STATUS) + ) + } + } + + @Test + fun testBuildTask_Status_Cancelled() { + buildTask { + status = Status.VTODO_CANCELLED + }.let { result -> + assertEquals( + TaskContract.Tasks.STATUS_CANCELLED, + result.getAsInteger(TaskContract.Tasks.STATUS) + ) + } + } + + @Test + fun testBuildTask_DtStart() { + buildTask { + dtStart = DtStart("20200703T155722", tzVienna) + }.let { result -> + Assert.assertEquals(1593784642000L, result.getAsLong(TaskContract.Tasks.DTSTART)) + assertEquals(tzVienna.id, result.getAsString(TaskContract.Tasks.TZ)) + assertEquals(0, result.getAsInteger(TaskContract.Tasks.IS_ALLDAY)) + } + } + + @Test + fun testBuildTask_DtStart_AllDay() { + buildTask { + dtStart = DtStart(Date("20200703")) + }.let { result -> + Assert.assertEquals(1593734400000L, result.getAsLong(TaskContract.Tasks.DTSTART)) + Assert.assertNull(result.get(TaskContract.Tasks.TZ)) + assertEquals(1, result.getAsInteger(TaskContract.Tasks.IS_ALLDAY)) + } + } + + @Test + fun testBuildTask_Due() { + buildTask { + due = Due(DateTime("20200703T155722", tzVienna)) + }.let { result -> + Assert.assertEquals(1593784642000L, result.getAsLong(TaskContract.Tasks.DUE)) + assertEquals(tzVienna.id, result.getAsString(TaskContract.Tasks.TZ)) + assertEquals(0, result.getAsInteger(TaskContract.Tasks.IS_ALLDAY)) + } + } + + @Test + fun testBuildTask_Due_AllDay() { + buildTask { + due = Due(Date("20200703")) + }.let { result -> + Assert.assertEquals(1593734400000L, result.getAsLong(TaskContract.Tasks.DUE)) + Assert.assertNull(result.getAsString(TaskContract.Tasks.TZ)) + assertEquals(1, result.getAsInteger(TaskContract.Tasks.IS_ALLDAY)) + } + } + + @Test + fun testBuildTask_DtStart_NonAllDay_Due_AllDay() { + buildTask { + dtStart = DtStart(DateTime("20200101T010203")) + due = Due(Date("20200201")) + }.let { result -> + assertEquals( + ZoneId.systemDefault().id, + result.getAsString(TaskContract.Tasks.TZ) + ) + assertEquals(0, result.getAsInteger(TaskContract.Tasks.IS_ALLDAY)) + } + } + + @Test + fun testBuildTask_DtStart_AllDay_Due_NonAllDay() { + buildTask { + dtStart = DtStart(Date("20200101")) + due = Due(DateTime("20200201T010203")) + }.let { result -> + Assert.assertNull(result.getAsString(TaskContract.Tasks.TZ)) + assertEquals(1, result.getAsInteger(TaskContract.Tasks.IS_ALLDAY)) + } + } + + @Test + fun testBuildTask_DtStart_AllDay_Due_AllDay() { + buildTask { + dtStart = DtStart(Date("20200101")) + due = Due(Date("20200201")) + }.let { result -> + assertEquals(1, result.getAsInteger(TaskContract.Tasks.IS_ALLDAY)) + } + } + + @Test + fun testBuildTask_DtStart_FloatingTime() { + buildTask { + dtStart = DtStart("20200703T010203") + }.let { result -> + Assert.assertEquals( + DateTime("20200703T010203").time, + result.getAsLong(TaskContract.Tasks.DTSTART) + ) + assertEquals( + ZoneId.systemDefault().id, + result.getAsString(TaskContract.Tasks.TZ) + ) + assertEquals(0, result.getAsInteger(TaskContract.Tasks.IS_ALLDAY)) + } + } + + @Test + fun testBuildTask_DtStart_Utc() { + buildTask { + dtStart = DtStart(DateTime(1593730923000), true) + }.let { result -> + Assert.assertEquals(1593730923000L, result.getAsLong(TaskContract.Tasks.DTSTART)) + assertEquals("Etc/UTC", result.getAsString(TaskContract.Tasks.TZ)) + assertEquals(0, result.getAsInteger(TaskContract.Tasks.IS_ALLDAY)) + } + } + + @Test + fun testBuildTask_Due_FloatingTime() { + buildTask { + due = Due("20200703T010203") + }.let { result -> + Assert.assertEquals( + DateTime("20200703T010203").time, + result.getAsLong(TaskContract.Tasks.DUE) + ) + assertEquals( + ZoneId.systemDefault().id, + result.getAsString(TaskContract.Tasks.TZ) + ) + assertEquals(0, result.getAsInteger(TaskContract.Tasks.IS_ALLDAY)) + } + } + + @Test + fun testBuildTask_Due_Utc() { + buildTask { + due = Due(DateTime(1593730923000).apply { isUtc = true }) + }.let { result -> + Assert.assertEquals(1593730923000L, result.getAsLong(TaskContract.Tasks.DUE)) + assertEquals("Etc/UTC", result.getAsString(TaskContract.Tasks.TZ)) + assertEquals(0, result.getAsInteger(TaskContract.Tasks.IS_ALLDAY)) + } + } + + @Test + fun testBuildTask_Duration() { + buildTask { + dtStart = DtStart(DateTime()) + duration = Duration(null, "P1D") + }.let { result -> + assertEquals("P1D", result.get(TaskContract.Tasks.DURATION)) + } + } + + @Test + fun testBuildTask_CompletedAt() { + val now = DateTime() + buildTask { + completedAt = Completed(now) + }.let { result -> + // Note: iCalendar does not allow COMPLETED to be all-day [RFC 5545 3.8.2.1] + assertEquals(0, result.getAsInteger(TaskContract.Tasks.COMPLETED_IS_ALLDAY)) + Assert.assertEquals(now.time, result.getAsLong(TaskContract.Tasks.COMPLETED)) + } + } + + @Test + fun testBuildTask_PercentComplete() { + buildTask { + percentComplete = 50 + }.let { result -> + assertEquals(50, result.getAsInteger(TaskContract.Tasks.PERCENT_COMPLETE)) + } + } + + @Test + fun testBuildTask_RRule() { + // Note: OpenTasks only supports one RRULE per VTODO (iCalendar: multiple RRULEs are allowed, but SHOULD not be used) + buildTask { + rRule = RRule("FREQ=DAILY;COUNT=10") + }.let { result -> + assertEquals("FREQ=DAILY;COUNT=10", result.getAsString(TaskContract.Tasks.RRULE)) + } + } + + @Test + fun testBuildTask_RDate() { + buildTask { + dtStart = DtStart(DateTime("20200101T010203", tzVienna)) + rDates += RDate(DateList("20200102T020304", Value.DATE_TIME, tzVienna)) + rDates += RDate(DateList("20200102T020304", Value.DATE_TIME, tzChicago)) + rDates += RDate(DateList("20200103T020304Z", Value.DATE_TIME)) + rDates += RDate(DateList("20200103", Value.DATE)) + }.let { result -> + assertEquals(tzVienna.id, result.getAsString(TaskContract.Tasks.TZ)) + assertEquals( + "20200102T020304,20200102T090304,20200103T020304Z,20200103T000000", + result.getAsString(TaskContract.Tasks.RDATE) + ) + } + } + + @Test + fun testBuildTask_ExDate() { + buildTask { + dtStart = DtStart(DateTime("20200101T010203", tzVienna)) + rRule = RRule("FREQ=DAILY;COUNT=10") + exDates += ExDate(DateList("20200102T020304", Value.DATE_TIME, tzVienna)) + exDates += ExDate(DateList("20200102T020304", Value.DATE_TIME, tzChicago)) + exDates += ExDate(DateList("20200103T020304Z", Value.DATE_TIME)) + exDates += ExDate(DateList("20200103", Value.DATE)) + }.let { result -> + assertEquals(tzVienna.id, result.getAsString(TaskContract.Tasks.TZ)) + assertEquals( + "20200102T020304,20200102T090304,20200103T020304Z,20200103T000000", + result.getAsString(TaskContract.Tasks.EXDATE) + ) + } + } + + @Test + fun testBuildTask_Categories() { + var hasCat1 = false + var hasCat2 = false + buildTask { + categories.addAll(arrayOf("Cat_1", "Cat 2")) + }.let { result -> + val id = result.getAsLong(TaskContract.Tasks._ID) + val uri = taskList!!.tasksPropertiesUri() + provider.client.query(uri, arrayOf(TaskContract.Property.Category.CATEGORY_NAME), "${TaskContract.Properties.MIMETYPE}=? AND ${TaskContract.PropertyColumns.TASK_ID}=?", + arrayOf(TaskContract.Property.Category.CONTENT_ITEM_TYPE, id.toString()), null)!!.use { cursor -> + while (cursor.moveToNext()) + when (cursor.getString(0)) { + "Cat_1" -> hasCat1 = true + "Cat 2" -> hasCat2 = true + } + } + } + Assert.assertTrue(hasCat1) + Assert.assertTrue(hasCat2) + } + + @Test + fun testBuildTask_Comment() { + var hasComment = false + buildTask { + comment = "Comment value" + }.let { result -> + val id = result.getAsLong(TaskContract.Tasks._ID) + val uri = taskList!!.tasksPropertiesUri() + provider.client.query(uri, arrayOf(TaskContract.Property.Comment.COMMENT), "${TaskContract.Properties.MIMETYPE}=? AND ${TaskContract.PropertyColumns.TASK_ID}=?", + arrayOf(TaskContract.Property.Comment.CONTENT_ITEM_TYPE, id.toString()), null)!!.use { cursor -> + if (cursor.moveToNext()) + hasComment = cursor.getString(0) == "Comment value" + } + } + Assert.assertTrue(hasComment) + } + + @Test + fun testBuildTask_Comment_empty() { + var hasComment: Boolean + buildTask { + comment = null + }.let { result -> + val id = result.getAsLong(TaskContract.Tasks._ID) + val uri = taskList!!.tasksPropertiesUri() + provider.client.query(uri, arrayOf(TaskContract.Property.Comment.COMMENT), "${TaskContract.Properties.MIMETYPE}=? AND ${TaskContract.PropertyColumns.TASK_ID}=?", + arrayOf(TaskContract.Property.Comment.CONTENT_ITEM_TYPE, id.toString()), null)!!.use { cursor -> + hasComment = cursor.count > 0 + } + } + Assert.assertFalse(hasComment) + } + + private fun firstProperty(taskId: Long, mimeType: String): ContentValues? { + val uri = taskList!!.tasksPropertiesUri() + provider.client.query(uri, null, "${TaskContract.Properties.MIMETYPE}=? AND ${TaskContract.PropertyColumns.TASK_ID}=?", + arrayOf(mimeType, taskId.toString()), null)!!.use { cursor -> + if (cursor.moveToNext()) { + val result = ContentValues(cursor.count) + DatabaseUtils.cursorRowToContentValues(cursor, result) + return result + } + } + return null + } + + @Test + fun testBuildTask_RelatedTo_Parent() { + buildTask { + relatedTo.add(RelatedTo("Parent-Task").apply { + parameters.add(RelType.PARENT) + }) + }.let { result -> + val taskId = result.getAsLong(TaskContract.Tasks._ID) + val relation = firstProperty(taskId, TaskContract.Property.Relation.CONTENT_ITEM_TYPE)!! + assertEquals( + "Parent-Task", + relation.getAsString(TaskContract.Property.Relation.RELATED_UID) + ) + Assert.assertNull(relation.get(TaskContract.Property.Relation.RELATED_ID)) // other task not in DB (yet) + assertEquals( + TaskContract.Property.Relation.RELTYPE_PARENT, + relation.getAsInteger(TaskContract.Property.Relation.RELATED_TYPE) + ) + } + } + + @Test + fun testBuildTask_RelatedTo_Child() { + buildTask { + relatedTo.add(RelatedTo("Child-Task").apply { + parameters.add(RelType.CHILD) + }) + }.let { result -> + val taskId = result.getAsLong(TaskContract.Tasks._ID) + val relation = firstProperty(taskId, TaskContract.Property.Relation.CONTENT_ITEM_TYPE)!! + assertEquals( + "Child-Task", + relation.getAsString(TaskContract.Property.Relation.RELATED_UID) + ) + Assert.assertNull(relation.get(TaskContract.Property.Relation.RELATED_ID)) // other task not in DB (yet) + assertEquals( + TaskContract.Property.Relation.RELTYPE_CHILD, + relation.getAsInteger(TaskContract.Property.Relation.RELATED_TYPE) + ) + } + } + + @Test + fun testBuildTask_RelatedTo_Sibling() { + buildTask { + relatedTo.add(RelatedTo("Sibling-Task").apply { + parameters.add(RelType.SIBLING) + }) + }.let { result -> + val taskId = result.getAsLong(TaskContract.Tasks._ID) + val relation = firstProperty(taskId, TaskContract.Property.Relation.CONTENT_ITEM_TYPE)!! + assertEquals( + "Sibling-Task", + relation.getAsString(TaskContract.Property.Relation.RELATED_UID) + ) + Assert.assertNull(relation.get(TaskContract.Property.Relation.RELATED_ID)) // other task not in DB (yet) + assertEquals( + TaskContract.Property.Relation.RELTYPE_SIBLING, + relation.getAsInteger(TaskContract.Property.Relation.RELATED_TYPE) + ) + } + } + + @Test + fun testBuildTask_RelatedTo_Custom() { + buildTask { + relatedTo.add(RelatedTo("Sibling-Task").apply { + parameters.add(RelType("custom-relationship")) + }) + }.let { result -> + val taskId = result.getAsLong(TaskContract.Tasks._ID) + val relation = firstProperty(taskId, TaskContract.Property.Relation.CONTENT_ITEM_TYPE)!! + assertEquals( + "Sibling-Task", + relation.getAsString(TaskContract.Property.Relation.RELATED_UID) + ) + Assert.assertNull(relation.get(TaskContract.Property.Relation.RELATED_ID)) // other task not in DB (yet) + assertEquals( + TaskContract.Property.Relation.RELTYPE_PARENT, + relation.getAsInteger(TaskContract.Property.Relation.RELATED_TYPE) + ) + } + } + + @Test + fun testBuildTask_RelatedTo_Default() { + buildTask { + relatedTo.add(RelatedTo("Parent-Task")) + }.let { result -> + val taskId = result.getAsLong(TaskContract.Tasks._ID) + val relation = firstProperty(taskId, TaskContract.Property.Relation.CONTENT_ITEM_TYPE)!! + assertEquals( + "Parent-Task", + relation.getAsString(TaskContract.Property.Relation.RELATED_UID) + ) + Assert.assertNull(relation.get(TaskContract.Property.Relation.RELATED_ID)) // other task not in DB (yet) + assertEquals( + TaskContract.Property.Relation.RELTYPE_PARENT, + relation.getAsInteger(TaskContract.Property.Relation.RELATED_TYPE) + ) + } + } + + + @Test + fun testBuildTask_UnknownProperty() { + val xProperty = XProperty("X-TEST-PROPERTY", "test-value").apply { + parameters.add(TzId(tzVienna.id)) + parameters.add(XParameter("X-TEST-PARAMETER", "12345")) + } + buildTask { + unknownProperties.add(xProperty) + }.let { result -> + val taskId = result.getAsLong(TaskContract.Tasks._ID) + val unknownProperty = firstProperty(taskId, UnknownProperty.CONTENT_ITEM_TYPE)!! + assertEquals( + xProperty, + UnknownProperty.fromJsonString(unknownProperty.getAsString(DmfsTask.UNKNOWN_PROPERTY_DATA)) + ) + } + } + + @Test + fun testBuildAllDayTask() { + // add all-day event to calendar provider + val task = Task() + task.summary = "All-day task" + task.description = "All-day task for testing" + task.location = "Sample location testBuildAllDayTask" + task.dtStart = DtStart(Date("20150501")) + task.due = Due(Date("20150502")) + Assert.assertTrue(task.isAllDay()) + val uri = DmfsTask(taskList!!, task, "9468a4cf-0d5b-4379-a704-12f1f84100ba", null, 0).add() + Assert.assertNotNull(uri) + + val testTask = taskList!!.getTask(ContentUris.parseId(uri)) + try { + // read again and verify result + val task2 = testTask.task!! + assertEquals(task.summary, task2.summary) + assertEquals(task.description, task2.description) + assertEquals(task.location, task2.location) + assertEquals(task.dtStart!!.date, task2.dtStart!!.date) + assertEquals(task.due!!.date, task2.due!!.date) + Assert.assertTrue(task2.isAllDay()) + } finally { + testTask.delete() + } + } + + + // other methods + + @Test + fun testGetTimeZone_noDateOrDateTime() { + val builder = DmfsTaskBuilder(taskList!!, Task(), 0, "9468a4cf-0d5b-4379-a704-12f1f84100ba", null, 0) + assertEquals(tzDefault, builder.getTimeZone()) + } + + @Test + fun testGetTimeZone_dtstart_with_date_and_no_time() { + val task = Task() + val builder = DmfsTaskBuilder(taskList!!, task, 0, "410c19d7-df79-4d65-8146-40b7bec5923b", null, 0) + val dmfsTask = DmfsTask(taskList!!, task, "410c19d7-df79-4d65-8146-40b7bec5923b", null, 0) + dmfsTask.task!!.dtStart = DtStart("20150101") + assertEquals(tzDefault, builder.getTimeZone()) + } + + @Test + fun testGetTimeZone_dtstart_with_time() { + val task = Task() + val builder = DmfsTaskBuilder(taskList!!, task, 0, "9468a4cf-0d5b-4379-a704-12f1f84100ba", null, 0) + val dmfsTask = DmfsTask(taskList!!, task, "9dc64544-1816-4f04-b952-e894164467f6", null, 0) + dmfsTask.task!!.dtStart = DtStart("20150101", tzVienna) + assertEquals(tzVienna, builder.getTimeZone()) + } + + + // helpers + + private fun buildTask(taskBuilder: Task.() -> Unit): ContentValues { + val task = Task().apply { + taskBuilder() + } + + val uri = DmfsTask(taskList!!, task, "9468a4cf-0d5b-4379-a704-12f1f84100ba", null, 0).add() + provider.client.query(uri, null, null, null, null)!!.use { + it.moveToNext() + val values = ContentValues() + DatabaseUtils.cursorRowToContentValues(it, values) + return values + } + } + +} \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/DmfsTask.kt b/lib/src/main/kotlin/at/bitfire/ical4android/DmfsTask.kt index eca00e40..04578330 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/DmfsTask.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/DmfsTask.kt @@ -10,49 +10,19 @@ import android.content.ContentUris import android.content.ContentValues import android.net.Uri import android.os.RemoteException +import at.bitfire.synctools.mapping.tasks.DmfsTaskBuilder +import at.bitfire.synctools.mapping.tasks.DmfsTaskProcessor import at.bitfire.synctools.storage.BatchOperation.CpoBuilder import at.bitfire.synctools.storage.LocalStorageException import at.bitfire.synctools.storage.tasks.DmfsTaskList import at.bitfire.synctools.storage.tasks.TasksBatchOperation import at.bitfire.synctools.storage.toContentValues -import at.bitfire.synctools.util.AndroidTimeUtils -import net.fortuna.ical4j.model.Date -import net.fortuna.ical4j.model.DateTime import net.fortuna.ical4j.model.Parameter -import net.fortuna.ical4j.model.Property -import net.fortuna.ical4j.model.PropertyList -import net.fortuna.ical4j.model.TimeZone -import net.fortuna.ical4j.model.TimeZoneRegistryFactory -import net.fortuna.ical4j.model.component.VAlarm -import net.fortuna.ical4j.model.parameter.Email import net.fortuna.ical4j.model.parameter.RelType -import net.fortuna.ical4j.model.parameter.Related -import net.fortuna.ical4j.model.property.Action -import net.fortuna.ical4j.model.property.Clazz -import net.fortuna.ical4j.model.property.Completed -import net.fortuna.ical4j.model.property.Description -import net.fortuna.ical4j.model.property.DtStart -import net.fortuna.ical4j.model.property.Due -import net.fortuna.ical4j.model.property.Duration -import net.fortuna.ical4j.model.property.ExDate -import net.fortuna.ical4j.model.property.Geo -import net.fortuna.ical4j.model.property.Organizer -import net.fortuna.ical4j.model.property.RDate -import net.fortuna.ical4j.model.property.RRule import net.fortuna.ical4j.model.property.RelatedTo -import net.fortuna.ical4j.model.property.Status -import net.fortuna.ical4j.model.property.Trigger -import net.fortuna.ical4j.util.TimeZones 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.Comment -import org.dmfs.tasks.contract.TaskContract.Property.Relation import org.dmfs.tasks.contract.TaskContract.Tasks import java.io.FileNotFoundException -import java.net.URISyntaxException -import java.time.ZoneId -import java.util.Locale import java.util.logging.Level import java.util.logging.Logger @@ -70,7 +40,6 @@ class DmfsTask( ) { private val logger = Logger.getLogger(javaClass.name) - private val tzRegistry by lazy { TimeZoneRegistryFactory.getInstance().createRegistry() } var id: Long? = null var syncId: String? = null @@ -116,15 +85,16 @@ class DmfsTask( val values = cursor.toContentValues() logger.log(Level.FINER, "Found task", values) - populateTask(values) + val processor = DmfsTaskProcessor(taskList) + processor.populateTask(values, newTask) if (values.containsKey(Properties.PROPERTY_ID)) { // process the first property, which is combined with the task row - populateProperty(values) + processor.populateProperty(values, newTask) while (cursor.moveToNext()) { // process the other properties - populateProperty(cursor.toContentValues()) + processor.populateProperty(cursor.toContentValues(), newTask) } } @@ -161,193 +131,21 @@ class DmfsTask( throw FileNotFoundException("Couldn't find task #$id") } - private fun populateTask(values: ContentValues) { - val task = requireNotNull(task) - - task.uid = values.getAsString(Tasks._UID) - task.sequence = values.getAsInteger(Tasks.SYNC_VERSION) - task.summary = values.getAsString(Tasks.TITLE) - task.location = values.getAsString(Tasks.LOCATION) - task.userAgents += taskList.providerName.packageName - - values.getAsString(Tasks.GEO)?.let { geo -> - val (lng, lat) = geo.split(',') - try { - task.geoPosition = Geo(lat.toBigDecimal(), lng.toBigDecimal()) - } catch (e: NumberFormatException) { - logger.log(Level.WARNING, "Invalid GEO value: $geo", e) - } - } - - task.description = values.getAsString(Tasks.DESCRIPTION) - task.color = values.getAsInteger(Tasks.TASK_COLOR) - task.url = values.getAsString(Tasks.URL) - - values.getAsString(Tasks.ORGANIZER)?.let { - try { - task.organizer = Organizer("mailto:$it") - } catch(e: URISyntaxException) { - logger.log(Level.WARNING, "Invalid ORGANIZER email", e) - } - } - - values.getAsInteger(Tasks.PRIORITY)?.let { task.priority = it } - - task.classification = when (values.getAsInteger(Tasks.CLASSIFICATION)) { - Tasks.CLASSIFICATION_PUBLIC -> Clazz.PUBLIC - Tasks.CLASSIFICATION_PRIVATE -> Clazz.PRIVATE - Tasks.CLASSIFICATION_CONFIDENTIAL -> Clazz.CONFIDENTIAL - else -> null - } - - values.getAsLong(Tasks.COMPLETED)?.let { task.completedAt = Completed(DateTime(it)) } - values.getAsInteger(Tasks.PERCENT_COMPLETE)?.let { task.percentComplete = it } - - task.status = when (values.getAsInteger(Tasks.STATUS)) { - Tasks.STATUS_IN_PROCESS -> Status.VTODO_IN_PROCESS - Tasks.STATUS_COMPLETED -> Status.VTODO_COMPLETED - Tasks.STATUS_CANCELLED -> Status.VTODO_CANCELLED - else -> Status.VTODO_NEEDS_ACTION - } - - val allDay = (values.getAsInteger(Tasks.IS_ALLDAY) ?: 0) != 0 - - val tzID = values.getAsString(Tasks.TZ) - val tz = tzID?.let { - val tzRegistry = TimeZoneRegistryFactory.getInstance().createRegistry() - tzRegistry.getTimeZone(it) - } - - values.getAsLong(Tasks.CREATED)?.let { task.createdAt = it } - values.getAsLong(Tasks.LAST_MODIFIED)?.let { task.lastModified = it } - - values.getAsLong(Tasks.DTSTART)?.let { dtStart -> - task.dtStart = - if (allDay) - DtStart(Date(dtStart)) - else { - val dt = DateTime(dtStart) - if (tz == null) - DtStart(dt, true) - else - DtStart(dt.apply { - timeZone = tz - }) - } - } - - values.getAsLong(Tasks.DUE)?.let { due -> - task.due = - if (allDay) - Due(Date(due)) - else { - val dt = DateTime(due) - if (tz == null) - Due(dt).apply { - isUtc = true - } - else - Due(dt.apply { - timeZone = tz - }) - } - } - - values.getAsString(Tasks.DURATION)?.let { duration -> - val fixedDuration = AndroidTimeUtils.parseDuration(duration) - task.duration = Duration(fixedDuration) - } - - values.getAsString(Tasks.RDATE)?.let { - task.rDates += AndroidTimeUtils.androidStringToRecurrenceSet(it, tzRegistry, allDay) { dates -> RDate(dates) } - } - values.getAsString(Tasks.EXDATE)?.let { - task.exDates += AndroidTimeUtils.androidStringToRecurrenceSet(it, tzRegistry, allDay) { dates -> ExDate(dates) } - } - - values.getAsString(Tasks.RRULE)?.let { task.rRule = RRule(it) } - } - - private fun populateProperty(row: ContentValues) { - logger.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) - Comment.CONTENT_ITEM_TYPE -> - task.comment = row.getAsString(Comment.COMMENT) - Relation.CONTENT_ITEM_TYPE -> - populateRelatedTo(row) - UnknownProperty.CONTENT_ITEM_TYPE -> - task.unknownProperties += UnknownProperty.fromJsonString(row.getAsString(UNKNOWN_PROPERTY_DATA)) - else -> - logger.warning("Found unknown property of type $type") - } - } - - private fun populateAlarm(row: ContentValues) { - val task = requireNotNull(task) - val props = PropertyList() - - val trigger = Trigger(java.time.Duration.ofMinutes(-row.getAsLong(Alarm.MINUTES_BEFORE))) - when (row.getAsInteger(Alarm.REFERENCE)) { - Alarm.ALARM_REFERENCE_START_DATE -> - trigger.parameters.add(Related.START) - Alarm.ALARM_REFERENCE_DUE_DATE -> - trigger.parameters.add(Related.END) - } - props += trigger - - props += when (row.getAsInteger(Alarm.ALARM_TYPE)) { - Alarm.ALARM_TYPE_EMAIL -> - Action.EMAIL - Alarm.ALARM_TYPE_SOUND -> - Action.AUDIO - else -> - // show alarm by default - Action.DISPLAY - } - - props += Description(row.getAsString(Alarm.MESSAGE) ?: task.summary) - - task.alarms += VAlarm(props) - } - - private fun populateRelatedTo(row: ContentValues) { - val uid = row.getAsString(Relation.RELATED_UID) - if (uid == null) { - logger.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) - } - - + /** + * Saves the unsaved [task] into the task provider storage. + * + * @return content URI of the created task + * + * @throws LocalStorageException when the tasks provider doesn't return a result row + * @throws RemoteException on tasks provider errors + */ fun add(): Uri { val batch = TasksBatchOperation(taskList.provider.client) - val builder = CpoBuilder.newInsert(taskList.tasksUri()) - buildTask(builder, false) - val idxTask = batch.nextBackrefIdx() - batch += builder - - insertProperties(batch, idxTask) + val requiredTask = requireNotNull(task) + val builder = DmfsTaskBuilder(taskList, requiredTask, id, syncId, eTag, flags) + val idxTask = builder.addRows(batch) + builder.insertProperties(batch, idxTask) batch.commit() @@ -357,6 +155,15 @@ class DmfsTask( 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 RemoteException on tasks provider errors + */ fun update(task: Task): Uri { this.task = task val existingId = requireNotNull(id) @@ -369,13 +176,11 @@ class DmfsTask( .withSelection("${Properties.TASK_ID}=?", arrayOf(existingId.toString())) // update task - val uri = taskSyncURI() - val builder = CpoBuilder.newUpdate(uri) - buildTask(builder, true) - batch += builder + val builder = DmfsTaskBuilder(taskList, task, id, syncId, eTag, flags) + builder.updateRows(batch) // insert task properties again - insertProperties(batch, null) + builder.insertProperties(batch, null) batch.commit() return ContentUris.withAppendedId(Tasks.getContentUri(taskList.providerName.authority), existingId) @@ -385,237 +190,17 @@ class DmfsTask( taskList.provider.client.update(taskSyncURI(), values, null, null) } - private fun insertProperties(batch: TasksBatchOperation, idxTask: Int?) { - insertAlarms(batch, idxTask) - insertCategories(batch, idxTask) - insertComment(batch, idxTask) - insertRelatedTo(batch, idxTask) - insertUnknownProperties(batch, idxTask) - } - - private fun insertAlarms(batch: TasksBatchOperation, idxTask: Int?) { - val task = requireNotNull(task) - for (alarm in task.alarms) { - val (alarmRef, minutes) = ICalendar.vAlarmToMin( - alarm = alarm, - refStart = task.dtStart, - refEnd = task.due, - refDuration = task.duration, - allowRelEnd = true - ) ?: continue - val ref = when (alarmRef) { - Related.END -> - Alarm.ALARM_REFERENCE_DUE_DATE - else /* Related.START is the default value */ -> - Alarm.ALARM_REFERENCE_START_DATE - } - - val alarmType = when (alarm.action?.value?.uppercase(Locale.ROOT)) { - Action.AUDIO.value -> - Alarm.ALARM_TYPE_SOUND - Action.DISPLAY.value -> - Alarm.ALARM_TYPE_MESSAGE - Action.EMAIL.value -> - Alarm.ALARM_TYPE_EMAIL - else -> - Alarm.ALARM_TYPE_NOTHING - } - - val builder = CpoBuilder - .newInsert(taskList.tasksPropertiesUri()) - .withTaskId(Alarm.TASK_ID, idxTask) - .withValue(Alarm.MIMETYPE, Alarm.CONTENT_ITEM_TYPE) - .withValue(Alarm.MINUTES_BEFORE, minutes) - .withValue(Alarm.REFERENCE, ref) - .withValue(Alarm.MESSAGE, alarm.description?.value ?: alarm.summary) - .withValue(Alarm.ALARM_TYPE, alarmType) - - logger.log(Level.FINE, "Inserting alarm", builder.build()) - batch += builder - } - } - - private fun insertCategories(batch: TasksBatchOperation, idxTask: Int?) { - for (category in requireNotNull(task).categories) { - val builder = CpoBuilder.newInsert(taskList.tasksPropertiesUri()) - .withTaskId(Category.TASK_ID, idxTask) - .withValue(Category.MIMETYPE, Category.CONTENT_ITEM_TYPE) - .withValue(Category.CATEGORY_NAME, category) - logger.log(Level.FINE, "Inserting category", builder.build()) - batch += builder - } - } - - private fun insertComment(batch: TasksBatchOperation, idxTask: Int?) { - val comment = requireNotNull(task).comment ?: return - val builder = CpoBuilder.newInsert(taskList.tasksPropertiesUri()) - .withTaskId(Comment.TASK_ID, idxTask) - .withValue(Comment.MIMETYPE, Comment.CONTENT_ITEM_TYPE) - .withValue(Comment.COMMENT, comment) - logger.log(Level.FINE, "Inserting comment", builder.build()) - batch += builder - } - - private fun insertRelatedTo(batch: TasksBatchOperation, idxTask: Int?) { - 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 = CpoBuilder.newInsert(taskList.tasksPropertiesUri()) - .withTaskId(Relation.TASK_ID, idxTask) - .withValue(Relation.MIMETYPE, Relation.CONTENT_ITEM_TYPE) - .withValue(Relation.RELATED_UID, relatedTo.value) - .withValue(Relation.RELATED_TYPE, relType) - logger.log(Level.FINE, "Inserting relation", builder.build()) - batch += builder - } - } - - private fun insertUnknownProperties(batch: TasksBatchOperation, idxTask: Int?) { - for (property in requireNotNull(task).unknownProperties) { - if (property.value.length > UnknownProperty.MAX_UNKNOWN_PROPERTY_SIZE) { - logger.warning("Ignoring unknown property with ${property.value.length} octets (too long)") - return - } - - val builder = CpoBuilder.newInsert(taskList.tasksPropertiesUri()) - .withTaskId(Properties.TASK_ID, idxTask) - .withValue(Properties.MIMETYPE, UnknownProperty.CONTENT_ITEM_TYPE) - .withValue(UNKNOWN_PROPERTY_DATA, UnknownProperty.toJsonString(property)) - logger.log(Level.FINE, "Inserting unknown property", builder.build()) - batch += builder - } - } - + /** + * Deletes an existing task from the tasks provider storage. + * + * @return number of affected rows + * + * @throws RemoteException on tasks provider errors + */ fun delete(): Int { return taskList.provider.client.delete(taskSyncURI(), null, null) } - private fun buildTask(builder: CpoBuilder, update: Boolean) { - if (!update) - builder .withValue(Tasks.LIST_ID, taskList.id) - - val task = requireNotNull(task) - 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) - .withValue(Tasks.GEO, task.geoPosition?.let { "${it.longitude},${it.latitude}" }) - .withValue(Tasks.DESCRIPTION, task.description) - .withValue(Tasks.TASK_COLOR, task.color) - .withValue(Tasks.URL, task.url) - - .withValue(Tasks._SYNC_ID, syncId) - .withValue(COLUMN_FLAGS, flags) - .withValue(COLUMN_ETAG, eTag) - - // parent_id will be re-calculated when the relation row is inserted (if there is any) - .withValue(Tasks.PARENT_ID, null) - - // organizer - task.organizer?.let { organizer -> - val uri = organizer.calAddress - val email = if (uri.scheme.equals("mailto", true)) - uri.schemeSpecificPart - else - organizer.getParameter(Parameter.EMAIL)?.value - if (email != null) - builder.withValue(Tasks.ORGANIZER, email) - else - logger.warning("Ignoring ORGANIZER without email address (not supported by Android)") - } - - // Priority, classification - builder .withValue(Tasks.PRIORITY, task.priority) - .withValue(Tasks.CLASSIFICATION, when (task.classification) { - Clazz.PUBLIC -> Tasks.CLASSIFICATION_PUBLIC - Clazz.CONFIDENTIAL -> Tasks.CLASSIFICATION_CONFIDENTIAL - null -> Tasks.CLASSIFICATION_DEFAULT - else -> Tasks.CLASSIFICATION_PRIVATE // all unknown classifications MUST be treated as PRIVATE - }) - - // COMPLETED must always be a DATE-TIME - builder .withValue(Tasks.COMPLETED, task.completedAt?.date?.time) - .withValue(Tasks.COMPLETED_IS_ALLDAY, 0) - .withValue(Tasks.PERCENT_COMPLETE, task.percentComplete) - - // Status - val status = when (task.status) { - Status.VTODO_IN_PROCESS -> Tasks.STATUS_IN_PROCESS - Status.VTODO_COMPLETED -> Tasks.STATUS_COMPLETED - Status.VTODO_CANCELLED -> Tasks.STATUS_CANCELLED - else -> Tasks.STATUS_DEFAULT // == Tasks.STATUS_NEEDS_ACTION - } - builder.withValue(Tasks.STATUS, status) - - // Time related - val allDay = task.isAllDay() - if (allDay) { - builder .withValue(Tasks.IS_ALLDAY, 1) - .withValue(Tasks.TZ, null) - } else { - AndroidTimeUtils.androidifyTimeZone(task.dtStart, tzRegistry) - AndroidTimeUtils.androidifyTimeZone(task.due, tzRegistry) - builder .withValue(Tasks.IS_ALLDAY, 0) - .withValue(Tasks.TZ, getTimeZone().id) - } - builder .withValue(Tasks.CREATED, task.createdAt) - .withValue(Tasks.LAST_MODIFIED, task.lastModified) - - .withValue(Tasks.DTSTART, task.dtStart?.date?.time) - .withValue(Tasks.DUE, task.due?.date?.time) - .withValue(Tasks.DURATION, task.duration?.value) - - .withValue(Tasks.RDATE, - if (task.rDates.isEmpty()) - null - else - AndroidTimeUtils.recurrenceSetsToOpenTasksString(task.rDates, if (allDay) null else getTimeZone())) - .withValue(Tasks.RRULE, task.rRule?.value) - - .withValue(Tasks.EXDATE, - if (task.exDates.isEmpty()) - null - else - AndroidTimeUtils.recurrenceSetsToOpenTasksString(task.exDates, if (allDay) null else getTimeZone())) - - logger.log(Level.FINE, "Built task object", builder.build()) - } - - - fun getTimeZone(): TimeZone { - val task = requireNotNull(task) - return task.dtStart?.let { dtStart -> - if (dtStart.isUtc) - tzRegistry.getTimeZone(TimeZones.UTC_ID) - else - dtStart.timeZone - } ?: - task.due?.let { due -> - if (due.isUtc) - tzRegistry.getTimeZone(TimeZones.UTC_ID) - else - due.timeZone - } ?: - tzRegistry.getTimeZone(ZoneId.systemDefault().id)!! - } - - - private fun CpoBuilder.withTaskId(column: String, idxTask: Int?): CpoBuilder { - if (idxTask != null) - withValueBackReference(column, idxTask) - else - withValue(column, requireNotNull(id)) - return this - } - - private fun taskSyncURI(loadProperties: Boolean = false): Uri { val id = requireNotNull(id) return ContentUris.withAppendedId(taskList.tasksUri(loadProperties), id) 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 new file mode 100644 index 00000000..fab70455 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/DmfsTaskBuilder.kt @@ -0,0 +1,296 @@ +/* + * 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.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.DmfsTaskList +import at.bitfire.synctools.storage.tasks.TasksBatchOperation +import at.bitfire.synctools.util.AndroidTimeUtils +import net.fortuna.ical4j.model.Parameter +import net.fortuna.ical4j.model.TimeZone +import net.fortuna.ical4j.model.TimeZoneRegistryFactory +import net.fortuna.ical4j.model.parameter.Email +import net.fortuna.ical4j.model.parameter.RelType +import net.fortuna.ical4j.model.parameter.Related +import net.fortuna.ical4j.model.property.Action +import net.fortuna.ical4j.model.property.Clazz +import net.fortuna.ical4j.model.property.Status +import net.fortuna.ical4j.util.TimeZones +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.Comment +import org.dmfs.tasks.contract.TaskContract.Property.Relation +import org.dmfs.tasks.contract.TaskContract.Tasks +import java.time.ZoneId +import java.util.Locale +import java.util.logging.Level +import java.util.logging.Logger + +/** + * Writes [at.bitfire.ical4android.Task] to dmfs task provider data rows + * (former DmfsTask "build..." methods). + */ +class DmfsTaskBuilder( + private val taskList: DmfsTaskList, + private val task: Task, + + // DmfsTask-level fields + private val id: Long?, + private val syncId: String?, + private val eTag: String?, + private val flags: Int, +) { + + private val logger + get() = Logger.getLogger(javaClass.name) + + private val tzRegistry by lazy { TimeZoneRegistryFactory.getInstance().createRegistry() } + + fun addRows(batch: TasksBatchOperation): Int { + val builder = CpoBuilder.newInsert(taskList.tasksUri()) + buildTask(builder, false) + val idxTask = batch.nextBackrefIdx() // Get nextBackrefIdx BEFORE adding builder to batch + batch += builder + return idxTask + } + + fun updateRows(batch: TasksBatchOperation) { + val id = requireNotNull(id) + val builder = CpoBuilder.newUpdate(taskList.taskUri(id)) + buildTask(builder, true) + batch += builder + } + + private fun buildTask(builder: CpoBuilder, update: Boolean) { + if (!update) + builder .withValue(Tasks.LIST_ID, taskList.id) + + 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) + .withValue(Tasks.GEO, task.geoPosition?.let { "${it.longitude},${it.latitude}" }) + .withValue(Tasks.DESCRIPTION, task.description) + .withValue(Tasks.TASK_COLOR, task.color) + .withValue(Tasks.URL, task.url) + + .withValue(Tasks._SYNC_ID, syncId) + .withValue(COLUMN_FLAGS, flags) + .withValue(COLUMN_ETAG, eTag) + + // parent_id will be re-calculated when the relation row is inserted (if there is any) + .withValue(Tasks.PARENT_ID, null) + + // organizer + task.organizer?.let { organizer -> + val uri = organizer.calAddress + val email = if (uri.scheme.equals("mailto", true)) + uri.schemeSpecificPart + else + organizer.getParameter(Parameter.EMAIL)?.value + if (email != null) + builder.withValue(Tasks.ORGANIZER, email) + else + logger.warning("Ignoring ORGANIZER without email address (not supported by Android)") + } + + // Priority, classification + builder .withValue(Tasks.PRIORITY, task.priority) + .withValue(Tasks.CLASSIFICATION, when (task.classification) { + Clazz.PUBLIC -> Tasks.CLASSIFICATION_PUBLIC + Clazz.CONFIDENTIAL -> Tasks.CLASSIFICATION_CONFIDENTIAL + null -> Tasks.CLASSIFICATION_DEFAULT + else -> Tasks.CLASSIFICATION_PRIVATE // all unknown classifications MUST be treated as PRIVATE + }) + + // COMPLETED must always be a DATE-TIME + builder .withValue(Tasks.COMPLETED, task.completedAt?.date?.time) + .withValue(Tasks.COMPLETED_IS_ALLDAY, 0) + .withValue(Tasks.PERCENT_COMPLETE, task.percentComplete) + + // Status + val status = when (task.status) { + Status.VTODO_IN_PROCESS -> Tasks.STATUS_IN_PROCESS + Status.VTODO_COMPLETED -> Tasks.STATUS_COMPLETED + Status.VTODO_CANCELLED -> Tasks.STATUS_CANCELLED + else -> Tasks.STATUS_DEFAULT // == Tasks.STATUS_NEEDS_ACTION + } + builder.withValue(Tasks.STATUS, status) + + // Time related + val allDay = task.isAllDay() + if (allDay) { + builder .withValue(Tasks.IS_ALLDAY, 1) + .withValue(Tasks.TZ, null) + } else { + AndroidTimeUtils.androidifyTimeZone(task.dtStart, tzRegistry) + AndroidTimeUtils.androidifyTimeZone(task.due, tzRegistry) + builder .withValue(Tasks.IS_ALLDAY, 0) + .withValue(Tasks.TZ, getTimeZone().id) + } + builder .withValue(Tasks.CREATED, task.createdAt) + .withValue(Tasks.LAST_MODIFIED, task.lastModified) + + .withValue(Tasks.DTSTART, task.dtStart?.date?.time) + .withValue(Tasks.DUE, task.due?.date?.time) + .withValue(Tasks.DURATION, task.duration?.value) + + .withValue(Tasks.RDATE, + if (task.rDates.isEmpty()) + null + else + AndroidTimeUtils.recurrenceSetsToOpenTasksString(task.rDates, if (allDay) null else getTimeZone())) + .withValue(Tasks.RRULE, task.rRule?.value) + + .withValue(Tasks.EXDATE, + if (task.exDates.isEmpty()) + null + else + AndroidTimeUtils.recurrenceSetsToOpenTasksString(task.exDates, if (allDay) null else getTimeZone())) + + logger.log(Level.FINE, "Built task object", builder.build()) + } + + fun getTimeZone(): TimeZone { + return task.dtStart?.let { dtStart -> + if (dtStart.isUtc) + tzRegistry.getTimeZone(TimeZones.UTC_ID) + else + dtStart.timeZone + } ?: + task.due?.let { due -> + if (due.isUtc) + tzRegistry.getTimeZone(TimeZones.UTC_ID) + else + due.timeZone + } ?: + tzRegistry.getTimeZone(ZoneId.systemDefault().id)!! + } + + fun insertProperties(batch: TasksBatchOperation, idxTask: Int?) { + insertAlarms(batch, idxTask) + insertCategories(batch, idxTask) + insertComment(batch, idxTask) + insertRelatedTo(batch, idxTask) + insertUnknownProperties(batch, idxTask) + } + + private fun insertAlarms(batch: TasksBatchOperation, idxTask: Int?) { + for (alarm in task.alarms) { + val (alarmRef, minutes) = ICalendar.vAlarmToMin( + alarm = alarm, + refStart = task.dtStart, + refEnd = task.due, + refDuration = task.duration, + allowRelEnd = true + ) ?: continue + val ref = when (alarmRef) { + Related.END -> + Alarm.ALARM_REFERENCE_DUE_DATE + else /* Related.START is the default value */ -> + Alarm.ALARM_REFERENCE_START_DATE + } + + val alarmType = when (alarm.action?.value?.uppercase(Locale.ROOT)) { + Action.AUDIO.value -> + Alarm.ALARM_TYPE_SOUND + Action.DISPLAY.value -> + Alarm.ALARM_TYPE_MESSAGE + Action.EMAIL.value -> + Alarm.ALARM_TYPE_EMAIL + else -> + Alarm.ALARM_TYPE_NOTHING + } + + val builder = CpoBuilder + .newInsert(taskList.tasksPropertiesUri()) + .withTaskId(Alarm.TASK_ID, idxTask) + .withValue(Alarm.MIMETYPE, Alarm.CONTENT_ITEM_TYPE) + .withValue(Alarm.MINUTES_BEFORE, minutes) + .withValue(Alarm.REFERENCE, ref) + .withValue(Alarm.MESSAGE, alarm.description?.value ?: alarm.summary) + .withValue(Alarm.ALARM_TYPE, alarmType) + + logger.log(Level.FINE, "Inserting alarm", builder.build()) + batch += builder + } + } + + private fun insertCategories(batch: TasksBatchOperation, idxTask: Int?) { + for (category in task.categories) { + val builder = CpoBuilder.newInsert(taskList.tasksPropertiesUri()) + .withTaskId(Category.TASK_ID, idxTask) + .withValue(Category.MIMETYPE, Category.CONTENT_ITEM_TYPE) + .withValue(Category.CATEGORY_NAME, category) + logger.log(Level.FINE, "Inserting category", builder.build()) + batch += builder + } + } + + private fun insertComment(batch: TasksBatchOperation, idxTask: Int?) { + val comment = task.comment ?: return + val builder = CpoBuilder.newInsert(taskList.tasksPropertiesUri()) + .withTaskId(Comment.TASK_ID, idxTask) + .withValue(Comment.MIMETYPE, Comment.CONTENT_ITEM_TYPE) + .withValue(Comment.COMMENT, comment) + logger.log(Level.FINE, "Inserting comment", builder.build()) + batch += builder + } + + private fun insertRelatedTo(batch: TasksBatchOperation, idxTask: Int?) { + for (relatedTo in 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 = CpoBuilder.newInsert(taskList.tasksPropertiesUri()) + .withTaskId(Relation.TASK_ID, idxTask) + .withValue(Relation.MIMETYPE, Relation.CONTENT_ITEM_TYPE) + .withValue(Relation.RELATED_UID, relatedTo.value) + .withValue(Relation.RELATED_TYPE, relType) + logger.log(Level.FINE, "Inserting relation", builder.build()) + batch += builder + } + } + + private fun insertUnknownProperties(batch: TasksBatchOperation, idxTask: Int?) { + for (property in task.unknownProperties) { + if (property.value.length > UnknownProperty.MAX_UNKNOWN_PROPERTY_SIZE) { + logger.warning("Ignoring unknown property with ${property.value.length} octets (too long)") + return + } + + val builder = CpoBuilder.newInsert(taskList.tasksPropertiesUri()) + .withTaskId(Properties.TASK_ID, idxTask) + .withValue(Properties.MIMETYPE, UnknownProperty.CONTENT_ITEM_TYPE) + .withValue(UNKNOWN_PROPERTY_DATA, UnknownProperty.toJsonString(property)) + logger.log(Level.FINE, "Inserting unknown property", builder.build()) + batch += builder + } + } + + private fun CpoBuilder.withTaskId(column: String, idxTask: Int?): CpoBuilder { + if (idxTask != null) + withValueBackReference(column, idxTask) + else + withValue(column, requireNotNull(id)) + return this + } + +} \ No newline at end of file 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 new file mode 100644 index 00000000..7a775f8f --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/DmfsTaskProcessor.kt @@ -0,0 +1,234 @@ +/* + * 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.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.DmfsTaskList +import at.bitfire.synctools.util.AndroidTimeUtils +import net.fortuna.ical4j.model.Date +import net.fortuna.ical4j.model.DateTime +import net.fortuna.ical4j.model.Property +import net.fortuna.ical4j.model.PropertyList +import net.fortuna.ical4j.model.TimeZoneRegistryFactory +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.Action +import net.fortuna.ical4j.model.property.Clazz +import net.fortuna.ical4j.model.property.Completed +import net.fortuna.ical4j.model.property.Description +import net.fortuna.ical4j.model.property.DtStart +import net.fortuna.ical4j.model.property.Due +import net.fortuna.ical4j.model.property.Duration +import net.fortuna.ical4j.model.property.ExDate +import net.fortuna.ical4j.model.property.Geo +import net.fortuna.ical4j.model.property.Organizer +import net.fortuna.ical4j.model.property.RDate +import net.fortuna.ical4j.model.property.RRule +import net.fortuna.ical4j.model.property.RelatedTo +import net.fortuna.ical4j.model.property.Status +import net.fortuna.ical4j.model.property.Trigger +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.Comment +import org.dmfs.tasks.contract.TaskContract.Property.Relation +import org.dmfs.tasks.contract.TaskContract.Tasks +import java.net.URISyntaxException +import java.util.logging.Level +import java.util.logging.Logger + +/** + * Reads dmfs task provider data rows into a [Task] + * (former DmfsTask "populate..." methods). + */ +class DmfsTaskProcessor( + private val taskList: DmfsTaskList +) { + + private val logger + get() = Logger.getLogger(javaClass.name) + + private val tzRegistry by lazy { TimeZoneRegistryFactory.getInstance().createRegistry() } + + fun populateTask(values: ContentValues, to: Task) { + to.uid = values.getAsString(Tasks._UID) + to.sequence = values.getAsInteger(Tasks.SYNC_VERSION) + to.summary = values.getAsString(Tasks.TITLE) + to.location = values.getAsString(Tasks.LOCATION) + to.userAgents += taskList.providerName.packageName + + values.getAsString(Tasks.GEO)?.let { geo -> + val (lng, lat) = geo.split(',') + try { + to.geoPosition = Geo(lat.toBigDecimal(), lng.toBigDecimal()) + } catch (e: NumberFormatException) { + logger.log(Level.WARNING, "Invalid GEO value: $geo", e) + } + } + + to.description = values.getAsString(Tasks.DESCRIPTION) + to.color = values.getAsInteger(Tasks.TASK_COLOR) + to.url = values.getAsString(Tasks.URL) + + values.getAsString(Tasks.ORGANIZER)?.let { + try { + to.organizer = Organizer("mailto:$it") + } catch(e: URISyntaxException) { + logger.log(Level.WARNING, "Invalid ORGANIZER email", e) + } + } + + values.getAsInteger(Tasks.PRIORITY)?.let { to.priority = it } + + to.classification = when (values.getAsInteger(Tasks.CLASSIFICATION)) { + Tasks.CLASSIFICATION_PUBLIC -> Clazz.PUBLIC + Tasks.CLASSIFICATION_PRIVATE -> Clazz.PRIVATE + Tasks.CLASSIFICATION_CONFIDENTIAL -> Clazz.CONFIDENTIAL + else -> null + } + + values.getAsLong(Tasks.COMPLETED)?.let { to.completedAt = Completed(DateTime(it)) } + values.getAsInteger(Tasks.PERCENT_COMPLETE)?.let { to.percentComplete = it } + + to.status = when (values.getAsInteger(Tasks.STATUS)) { + Tasks.STATUS_IN_PROCESS -> Status.VTODO_IN_PROCESS + Tasks.STATUS_COMPLETED -> Status.VTODO_COMPLETED + Tasks.STATUS_CANCELLED -> Status.VTODO_CANCELLED + else -> Status.VTODO_NEEDS_ACTION + } + + val allDay = (values.getAsInteger(Tasks.IS_ALLDAY) ?: 0) != 0 + + val tzID = values.getAsString(Tasks.TZ) + val tz = tzID?.let { + val tzRegistry = TimeZoneRegistryFactory.getInstance().createRegistry() + tzRegistry.getTimeZone(it) + } + + values.getAsLong(Tasks.CREATED)?.let { to.createdAt = it } + values.getAsLong(Tasks.LAST_MODIFIED)?.let { to.lastModified = it } + + values.getAsLong(Tasks.DTSTART)?.let { dtStart -> + to.dtStart = + if (allDay) + DtStart(Date(dtStart)) + else { + val dt = DateTime(dtStart) + if (tz == null) + DtStart(dt, true) + else + DtStart(dt.apply { + timeZone = tz + }) + } + } + + values.getAsLong(Tasks.DUE)?.let { due -> + to.due = + if (allDay) + Due(Date(due)) + else { + val dt = DateTime(due) + if (tz == null) + Due(dt).apply { + isUtc = true + } + else + Due(dt.apply { + timeZone = tz + }) + } + } + + values.getAsString(Tasks.DURATION)?.let { duration -> + val fixedDuration = AndroidTimeUtils.parseDuration(duration) + to.duration = Duration(fixedDuration) + } + + values.getAsString(Tasks.RDATE)?.let { + to.rDates += AndroidTimeUtils.androidStringToRecurrenceSet(it, tzRegistry, allDay) { dates -> RDate(dates) } + } + values.getAsString(Tasks.EXDATE)?.let { + to.exDates += AndroidTimeUtils.androidStringToRecurrenceSet(it, tzRegistry, allDay) { dates -> ExDate(dates) } + } + + values.getAsString(Tasks.RRULE)?.let { to.rRule = RRule(it) } + } + + fun populateProperty(row: ContentValues, to: Task) { + logger.log(Level.FINER, "Found property", row) + + when (val type = row.getAsString(Properties.MIMETYPE)) { + Alarm.CONTENT_ITEM_TYPE -> + populateAlarm(row, to) + Category.CONTENT_ITEM_TYPE -> + to.categories += row.getAsString(Category.CATEGORY_NAME) + Comment.CONTENT_ITEM_TYPE -> + to.comment = row.getAsString(Comment.COMMENT) + Relation.CONTENT_ITEM_TYPE -> + populateRelatedTo(row, to) + UnknownProperty.CONTENT_ITEM_TYPE -> + to.unknownProperties += UnknownProperty.fromJsonString(row.getAsString(UNKNOWN_PROPERTY_DATA)) + else -> + logger.warning("Found unknown property of type $type") + } + } + + private fun populateAlarm(row: ContentValues, to: Task) { + val props = PropertyList() + + val trigger = Trigger(java.time.Duration.ofMinutes(-row.getAsLong(Alarm.MINUTES_BEFORE))) + when (row.getAsInteger(Alarm.REFERENCE)) { + Alarm.ALARM_REFERENCE_START_DATE -> + trigger.parameters.add(Related.START) + Alarm.ALARM_REFERENCE_DUE_DATE -> + trigger.parameters.add(Related.END) + } + props += trigger + + props += when (row.getAsInteger(Alarm.ALARM_TYPE)) { + Alarm.ALARM_TYPE_EMAIL -> + Action.EMAIL + Alarm.ALARM_TYPE_SOUND -> + Action.AUDIO + else -> + // show alarm by default + Action.DISPLAY + } + + props += Description(row.getAsString(Alarm.MESSAGE) ?: to.summary) + + to.alarms += VAlarm(props) + } + + private fun populateRelatedTo(row: ContentValues, to: Task) { + val uid = row.getAsString(Relation.RELATED_UID) + if (uid == null) { + logger.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 + }) + + to.relatedTo.add(relatedTo) + } + +} \ 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 cffbd563..d72bc4c8 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 @@ -149,6 +149,9 @@ class DmfsTaskList( uri } + fun taskUri(id: Long, loadProperties: Boolean = false): Uri = + ContentUris.withAppendedId(tasksUri(loadProperties), id) + fun tasksPropertiesUri() = TaskContract.Properties.getContentUri(providerName.authority).asSyncAdapter(account)