diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 56236e8f1b87d169815d348f24eade1579ff5a1b..26cab5b72114748a0b0b1dc63b9ff73ec4ecfbdc 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -15,4 +15,4 @@ cache: build: stage: build script: - - ./gradlew build \ No newline at end of file + - ./gradlew build diff --git a/README.md b/README.md index a26788be52086a6170846d356927180741ef2d3c..0c370c6823e83e97c45b25f6e5d84ca69d499b61 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # ical4android -ical4android is an Android library that brings together iCalendar and Android. +ical4android is a library for Android that brings together iCalendar and Android. It's a framework for * parsing and generating iCalendar resources (using [ical4j](https://github.com/ical4j/ical4j)) @@ -14,11 +14,16 @@ It's a framework for It has been primarily developed for: -* [DAVdroid](https://www.davdroid.com) -* [ICSdroid](https://icsdroid.bitfire.at) +* [DAVx⁵](https://www.davx5.com) +* [ICSx⁵](https://icsx5.bitfire.at) + +_This software is not affiliated to, nor has it been authorized, sponsored or otherwise approved +by Google LLC. Android is a trademark of Google LLC._ Generated KDoc: https://bitfireAT.gitlab.io/ical4android/dokka/ical4android/ +Discussion: https://forums.bitfire.at/category/18/libraries + ## Contact @@ -30,13 +35,10 @@ Florastraße 27 Email: [play@bitfire.at](mailto:play@bitfire.at) (do not use this) -For questions, suggestions etc. please use the DAVdroid forum: -https://www.davdroid.com/forums/ - ## License -Copyright (C) bitfire web engineering (Ricki Hirner, Bernhard Stockmann). +Copyright (C) bitfire web engineering (Ricki Hirner, Bernhard Stockmann) and contributors. This program comes with ABSOLUTELY NO WARRANTY. This is free software, and you are welcome to redistribute it under the conditions of the [GNU GPL v3](https://www.gnu.org/licenses/gpl-3.0.html). diff --git a/build.gradle b/build.gradle index 886e91920f8d1f955313ff82a6cbee9435ddd32e..a63a6eb33355db72599980df6fe5e9ca42060192 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,10 @@ buildscript { - ext.kotlin_version = '1.3.10' - ext.dokka_version = '0.9.17' + ext.versions = [ + kotlin: '1.3.61', + dokka: '0.10.0', + ical4j: '2.2.6' + ] repositories { google() @@ -9,9 +12,9 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:3.2.1' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - classpath "org.jetbrains.dokka:dokka-android-gradle-plugin:${dokka_version}" + classpath 'com.android.tools.build:gradle:3.5.3' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${versions.kotlin}" + classpath "org.jetbrains.dokka:dokka-gradle-plugin:${versions.dokka}" } } @@ -22,30 +25,21 @@ repositories { apply plugin: 'com.android.library' apply plugin: 'kotlin-android' -apply plugin: 'org.jetbrains.dokka-android' - -ext { - ical4j_version = '2.2.0' -} +apply plugin: 'org.jetbrains.dokka' android { - compileSdkVersion 27 - buildToolsVersion '27.1.1' + compileSdkVersion 29 + buildToolsVersion '29.0.2' defaultConfig { - minSdkVersion 24 - targetSdkVersion 27 + minSdkVersion 19 + targetSdkVersion 29 - testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - buildConfigField "String", "version_ical4j", "\"$ical4j_version\"" - } - buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' - } + buildConfigField "String", "version_ical4j", "\"${versions.ical4j}\"" } + lintOptions { disable 'AllowBackup' disable 'InvalidPackage' @@ -58,16 +52,31 @@ android { sourceSets { main.java.srcDirs = [ "src/main/java", "opentasks-contract/src/main/java" ] } + + dokka.configuration { + sourceLink { + url = "https://gitlab.com/bitfireAT/ical4android/tree/master/" + lineSuffix = "#L" + } + jdkVersion = 7 + } } dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${versions.kotlin}" + + api "org.mnode.ical4j:ical4j:${versions.ical4j}" + implementation 'org.slf4j:slf4j-jdk14:1.7.26' + implementation 'androidx.core:core-ktx:1.1.0' - api "org.mnode.ical4j:ical4j:$ical4j_version" - implementation 'org.slf4j:slf4j-jdk14:1.7.25' + androidTestImplementation 'androidx.test:core:1.2.0' + androidTestImplementation 'androidx.test:runner:1.2.0' + androidTestImplementation 'androidx.test:rules:1.2.0' - androidTestImplementation 'com.android.support.test:runner:1.0.2' - androidTestImplementation 'com.android.support.test:rules:1.0.2' + // This very old Groovy version is not actually used, but it's needed + // so that ical4j doesn't crash when testing: https://github.com/ical4j/ical4j/issues/315 + //noinspection GradleDependency + androidTestImplementation 'org.codehaus.groovy:groovy:2.2.2' testImplementation 'junit:junit:4.12' } diff --git a/gradle.properties b/gradle.properties index 8bd86f6805108dec87d0be823bdb1384bec8aa19..d9cf55df7c1945850a53322a299a58f4b3229097 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1,2 @@ org.gradle.jvmargs=-Xmx1536M +android.useAndroidX=true diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 5801d8e8035b2cc3012598ce30a163b41d437654..dcc77281b370672855b0e5f012b67acf3db3e2a6 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Tue Aug 23 16:42:17 CEST 2016 +#Wed Apr 17 22:31:47 CEST 2019 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-4.8-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-5.5.1-all.zip diff --git a/opentasks-contract/src/main/java/org/dmfs/tasks/contract/TaskContract.java b/opentasks-contract/src/main/java/org/dmfs/tasks/contract/TaskContract.java index bcbaeece78f6543d2cf87508088716f46f2c42d1..d8363ddbfbe0fffb3e72ddc453575df92e224fb7 100644 --- a/opentasks-contract/src/main/java/org/dmfs/tasks/contract/TaskContract.java +++ b/opentasks-contract/src/main/java/org/dmfs/tasks/contract/TaskContract.java @@ -512,7 +512,7 @@ public final class TaskContract String DESCRIPTION = "description"; /** - * An URL for this task. Must be a valid URL if not null- + * The URL iCalendar field for this task. Must be a valid URI if not null- *

* Value: String *

@@ -1036,27 +1036,80 @@ public final class TaskContract String INSTANCE_DURATION = "instance_duration"; /** - * The start of the original instance as specified in the master task. For non-recurring task instances this equals the value of {@link - * #INSTANCE_START}, except that `null` values are represented as `0`. + * The start of the original instance as specified in the master task. For non-recurring task instances this is {@code null}. *

* For recurring tasks, these are the timestamps which have been derived from the recurrence rule or dates, except those specified as exdates. */ String INSTANCE_ORIGINAL_TIME = "instance_original_time"; + /** + * The distance of the instance from the current one. For closed instances this is always {@code -1}, for the current instance this is {@code 0}. For + * the instance after the current one this is {@code 1}, for the instance after that one it's {@code 2}, etc.. + *

+ * Value: Integer + *

+ * read-only + */ + String DISTANCE_FROM_CURRENT = "distance_from_current"; } /** - * Instances of a task. At present this table is read only. Currently it contains exactly one entry per task (and task exception), so it's merely a copy of - * {@link Tasks}. + * A table containing one entry per task instance. This table is writable in order to allow modification of single instances of a task. Write operations to + * this table will be converted into operations on overrides and forwarded to the task table. + *

+ * Note: The {@link #DTSTART}, {@link #DUE} values of instances of recurring tasks represent the actual instance values, i.e. they are different for each + * instance ({@link #DURATION} is always {@code null}). + *

+ * Also, none of the instances are recurring themselves, so {@link #RRULE}, {@link #RDATE} and {@link #EXDATE} are always {@code null}. + *

+ * TODO: Insert all instances of recurring tasks. + *

+ * The following operations are supported: *

- * TODO: Insert all instances of recurring the tasks. - *

+ *

Insert

*

- * TODO: In later releases it's planned to provide a convenient interface to add, change or delete task instances via this URI. - *

+ * Note, the data of an insert must not contain the fields {@link #RRULE}, {@link #RDATE} or {@link #EXDATE}. If the new instance belongs to an existing + * task the data must contain the fields {@link #ORIGINAL_INSTANCE_ID} and {@link #ORIGINAL_INSTANCE_TIME}. Also note, this table supports writing {@link + * #DURATION} (if the instance has a {@link #DTSTART}), but reading it back will always return a {@code null} {@link #DURATION} and a non-{@code null} + * {@link #DUE} date. Reading the task in the tasks table will, however, return the original {@link #DURATION}. + *

+ * If there already is an instance (with or without override) for the given {@link #ORIGINAL_INSTANCE_ID} and {@link #ORIGINAL_INSTANCE_TIME} an exception + * is thrown. + *

+ *
ORIGINAL_INSTANCE_ID valueResult
absent or emptyA new non-recurring task is created with the given + * values.
a valid {@link Tasks} row {@code _ID}An {@link #RDATE} for the given {@link #ORIGINAL_INSTANCE_TIME} time is added to + * the given master task, any {@link #EXDATE} for this time is removed. The task is inserted as an override to the given master. No fields are inherited + * though. {@link #ORIGINAL_INSTANCE_ALLDAY} will be set to {@link #IS_ALLDAY} of the master. + *

+ * Note, if the given master is non-recurring, this operation will turn it into a recurring task.

invalid {@link Tasks} row {@code + * _ID}An exception is thrown.
+ *

+ *

Update

+ *

+ * Note, the data of an update must not contain any fields related to recurrence ({@link #RRULE}, {@link #RDATE}, {@link #EXDATE}, {@link + * #ORIGINAL_INSTANCE_ID}, {@link #ORIGINAL_INSTANCE_TIME} and {@link #ORIGINAL_INSTANCE_ALLDAY}). Also note, this table supports writing {@link #DURATION} + * (if the instance has a {@link #DTSTART}), but reading it back will always return a {@code null} {@link #DURATION} and a non-{@code null} {@link #DUE} + * date. Reading the task in the tasks table will, however, return the original {@link #DURATION}. + *

+ * + *
Target task typeResult
Recurring master taskA new override is created with the given data.

Note, + * any fields which are not provided are inherited from the master, except for {@link #DTSTART} and {@link #DUE} which will be inherited from the instance + * and {@link #DURATION}, {@link #RRULE}, {@link #RDATE} and {@link #EXDATE} which are set to {@code null}. {@link #ORIGINAL_INSTANCE_ID}, {@link + * #ORIGINAL_INSTANCE_TIME} and {@link #ORIGINAL_INSTANCE_ALLDAY} will be set accordingly.

Single instance taskThe task is + * updated with the given values.
Recurrence override with existing masterThe task is updated with the given values.
Recurrence override without existing masterThe task is updated with the given values.
+ *

+ *

Delete

+ *

+ * + * + *
Target task typeResult
Recurring master taskAn {@link #EXDATE} for this instance is added, any {@link + * #RDATE} for this instance is removed. The instance row is removed.

TODO: mark the task deleted if the remaining recurrence set is empty

Single instance taskThe {@link Tasks#_DELETED} flag of the task is set.
Recurrence override with existing + * masterThe {@link Tasks#_DELETED} flag of the override is set, an {@link #EXDATE} for this instance is added to the master, any {@link #RDATE} + * for this instance is removed from the master. TODO: mark the master deleted if the remaining recurrence set of the master is empty
Recurrence override without existing masterThe {@link Tasks#_DELETED} flag of the task is set.
* - * @author Yannic Ahrens + * @author Yannic Ahrens + * @author Marten Gajda */ public static final class Instances implements TaskColumns, InstanceColumns { diff --git a/opentasks-contract/src/main/java/org/dmfs/tasks/contract/UriFactory.java b/opentasks-contract/src/main/java/org/dmfs/tasks/contract/UriFactory.java index 8efbd7fc0c210d1e7b8c678818eadbcec83be898..0092aca26ac777dd35c987ee402d257072336675 100644 --- a/opentasks-contract/src/main/java/org/dmfs/tasks/contract/UriFactory.java +++ b/opentasks-contract/src/main/java/org/dmfs/tasks/contract/UriFactory.java @@ -34,7 +34,7 @@ public final class UriFactory UriFactory(String authority) { mAuthority = authority; - mUriMap.put((String) null, Uri.parse("content://" + authority)); + mUriMap.put(null, Uri.parse("content://" + authority)); } diff --git a/src/androidTest/java/foundation/e/ical4android/AndroidCalendarTest.kt b/src/androidTest/java/foundation/e/ical4android/AndroidCalendarTest.kt index 709ae83777f989196556887b64f0d012e6948553..6c9465f7bdc233e0de50bb19beeb3d4dae98f7f0 100644 --- a/src/androidTest/java/foundation/e/ical4android/AndroidCalendarTest.kt +++ b/src/androidTest/java/foundation/e/ical4android/AndroidCalendarTest.kt @@ -15,9 +15,9 @@ import android.content.ContentUris import android.content.ContentValues import android.os.Build import android.provider.CalendarContract -import android.support.test.InstrumentationRegistry -import android.support.test.filters.MediumTest -import android.support.test.rule.GrantPermissionRule +import androidx.test.filters.MediumTest +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule import foundation.e.ical4android.impl.TestCalendar import org.junit.After import org.junit.Assert.assertEquals @@ -41,11 +41,12 @@ class AndroidCalendarTest { @Before fun prepare() { - provider = InstrumentationRegistry.getInstrumentation().targetContext.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY) + provider = InstrumentationRegistry.getInstrumentation().targetContext.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!! } @After fun shutdown() { + @Suppress("DEPRECATION") if (Build.VERSION.SDK_INT >= 24) provider.close() else diff --git a/src/androidTest/java/foundation/e/ical4android/AndroidEventTest.kt b/src/androidTest/java/foundation/e/ical4android/AndroidEventTest.kt index d4572f551d1aea60334d81b366d67051d12570c6..2b902dd984805caa7f04d447aa1fe98cae2484f4 100644 --- a/src/androidTest/java/foundation/e/ical4android/AndroidEventTest.kt +++ b/src/androidTest/java/foundation/e/ical4android/AndroidEventTest.kt @@ -15,9 +15,9 @@ import android.content.ContentValues import android.net.Uri import android.os.Build import android.provider.CalendarContract -import android.support.test.InstrumentationRegistry.getInstrumentation -import android.support.test.filters.MediumTest -import android.support.test.rule.GrantPermissionRule +import androidx.test.filters.MediumTest +import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation +import androidx.test.rule.GrantPermissionRule import foundation.e.ical4android.AndroidCalendar.Companion.syncAdapterURI import foundation.e.ical4android.impl.TestCalendar import foundation.e.ical4android.impl.TestEvent @@ -53,7 +53,7 @@ class AndroidEventTest { @Before fun prepare() { - provider = getInstrumentation().targetContext.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY) + provider = getInstrumentation().targetContext.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!! AndroidCalendar.insertColors(provider, testAccount) @@ -67,6 +67,7 @@ class AndroidEventTest { fun shutdown() { calendar.delete() + @Suppress("DEPRECATION") if (Build.VERSION.SDK_INT >= 24) provider.close() else @@ -79,17 +80,17 @@ class AndroidEventTest { fun testAddEvent() { // build and write recurring event to calendar provider val event = Event() - event.uid = ("sample1@testAddEvent") - event.summary = ("Sample event") - event.description = ("Sample event with date/time") - event.location = ("Sample location") - event.dtStart = (DtStart("20150501T120000", tzVienna)) - event.dtEnd = (DtEnd("20150501T130000", tzVienna)) - event.organizer = (Organizer(URI("mailto:organizer@example.com"))) - event.rRule = (RRule("FREQ=DAILY;COUNT=10")) - event.classification = (Clazz.PRIVATE) - event.status = (Status.VEVENT_CONFIRMED) - event.color = (EventColor.aliceblue) + event.uid = "sample1@testAddEvent" + event.summary = "Sample event" + event.description = "Sample event with date/time" + event.location = "Sample location" + event.dtStart = DtStart("20150501T120000", tzVienna) + event.dtEnd = DtEnd("20150501T130000", tzVienna) + event.organizer = Organizer(URI("mailto:organizer@example.com")) + event.rRule = RRule("FREQ=DAILY;COUNT=10") + event.classification = Clazz.PRIVATE + event.status = Status.VEVENT_CONFIRMED + event.color = Css3Color.aliceblue assertFalse(event.isAllDay()) // TODO test rDates, exDate, duration @@ -114,6 +115,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) @@ -171,6 +176,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() } diff --git a/src/androidTest/java/foundation/e/ical4android/AndroidTaskListTest.kt b/src/androidTest/java/foundation/e/ical4android/AndroidTaskListTest.kt index b84367602eec57b6487fd3d213ca163db465bec7..84fddd908248428aac41b3a4aa6842ef19109763 100644 --- a/src/androidTest/java/foundation/e/ical4android/AndroidTaskListTest.kt +++ b/src/androidTest/java/foundation/e/ical4android/AndroidTaskListTest.kt @@ -11,13 +11,17 @@ package foundation.e.ical4android import android.accounts.Account import android.content.ContentUris import android.content.ContentValues -import android.support.test.InstrumentationRegistry.getInstrumentation -import android.support.test.filters.MediumTest +import android.database.DatabaseUtils +import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import foundation.e.ical4android.impl.TestTaskList +import foundation.e.ical4android.impl.TestTask +import net.fortuna.ical4j.model.property.RelatedTo import org.dmfs.tasks.contract.TaskContract +import org.dmfs.tasks.contract.TaskContract.Properties +import org.dmfs.tasks.contract.TaskContract.Property.Relation +import org.dmfs.tasks.contract.TaskContract.Tasks import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull +import org.junit.Assert.* import org.junit.Assume.assumeNotNull import org.junit.Before import org.junit.Test @@ -43,35 +47,93 @@ class AndroidTaskListTest { provider?.close() } - - @MediumTest - @Test - fun testManageTaskLists() { - // create task list + private fun createTaskList(): TestTaskList { val info = ContentValues() info.put(TaskContract.TaskLists.LIST_NAME, "Test Task List") info.put(TaskContract.TaskLists.LIST_COLOR, 0xffff0000) info.put(TaskContract.TaskLists.OWNER, "test@example.com") info.put(TaskContract.TaskLists.SYNC_ENABLED, 1) info.put(TaskContract.TaskLists.VISIBLE, 1) + val uri = AndroidTaskList.create(testAccount, provider!!, info) assertNotNull(uri) - // query task list - val taskList = AndroidTaskList.findByID(testAccount, provider!!, TestTaskList.Factory, ContentUris.parseId(uri)) - assertNotNull(taskList) + return AndroidTaskList.findByID(testAccount, provider!!, TestTaskList.Factory, ContentUris.parseId(uri)) + } + + + @Test + fun testManageTaskLists() { + val taskList = createTaskList() + + try { + // sync URIs + assertEquals("true", taskList.taskListSyncUri().getQueryParameter(TaskContract.CALLER_IS_SYNCADAPTER)) + assertEquals(testAccount.type, taskList.taskListSyncUri().getQueryParameter(TaskContract.ACCOUNT_TYPE)) + assertEquals(testAccount.name, taskList.taskListSyncUri().getQueryParameter(TaskContract.ACCOUNT_NAME)) + + assertEquals("true", taskList.tasksSyncUri().getQueryParameter(TaskContract.CALLER_IS_SYNCADAPTER)) + assertEquals(testAccount.type, taskList.tasksSyncUri().getQueryParameter(TaskContract.ACCOUNT_TYPE)) + assertEquals(testAccount.name, taskList.tasksSyncUri().getQueryParameter(TaskContract.ACCOUNT_NAME)) + } finally { + // delete task list + assertEquals(1, taskList.delete()) + } + } + + @Test + fun testTouchRelations() { + val taskList = createTaskList() + try { + val parent = Task() + parent.uid = "parent" + parent.summary = "Parent task" + + val child = Task() + child.uid = "child" + child.summary = "Child task" + child.relatedTo.add(RelatedTo(parent.uid)) + + // insert child before parent + val childContentUri = TestTask(taskList, child).add() + val childId = ContentUris.parseId(childContentUri) + val parentContentUri = TestTask(taskList, parent).add() + val parentId = ContentUris.parseId(parentContentUri) + + // OpenTasks should provide the correct relation… + taskList.provider.client.query(taskList.tasksPropertiesSyncUri(), null, + "${Properties.TASK_ID}=?", arrayOf(childId.toString()), + null, null)!!.use { cursor -> + assertEquals(1, cursor.count) + cursor.moveToNext() + + val row = ContentValues() + DatabaseUtils.cursorRowToContentValues(cursor, row) - // sync URIs - assertEquals("true", taskList.taskListSyncUri().getQueryParameter(TaskContract.CALLER_IS_SYNCADAPTER)) - assertEquals(testAccount.type, taskList.taskListSyncUri().getQueryParameter(TaskContract.ACCOUNT_TYPE)) - assertEquals(testAccount.name, taskList.taskListSyncUri().getQueryParameter(TaskContract.ACCOUNT_NAME)) + assertEquals(Relation.CONTENT_ITEM_TYPE, row.getAsString(Properties.MIMETYPE)) + assertEquals(parentId, row.getAsLong(Relation.RELATED_ID)) + assertEquals(parent.uid, row.getAsString(Relation.RELATED_UID)) + assertEquals(Relation.RELTYPE_PARENT, row.getAsInteger(Relation.RELATED_TYPE)) + } + // … BUT the parent_id is not updated (https://github.com/dmfs/opentasks/issues/877) + taskList.provider.client.query(childContentUri, arrayOf(Tasks.PARENT_ID), + null, null, null)!!.use { cursor -> + assertTrue(cursor.moveToNext()) + assertTrue(cursor.isNull(0)) + } - assertEquals("true", taskList.tasksSyncUri().getQueryParameter(TaskContract.CALLER_IS_SYNCADAPTER)) - assertEquals(testAccount.type, taskList.tasksSyncUri().getQueryParameter(TaskContract.ACCOUNT_TYPE)) - assertEquals(testAccount.name, taskList.tasksSyncUri().getQueryParameter(TaskContract.ACCOUNT_NAME)) + // touch the relations to update parent_id values + taskList.touchRelations() - // delete task list - assertEquals(1, taskList.delete()) + // now parent_id should bet set + taskList.provider.client.query(childContentUri, arrayOf(Tasks.PARENT_ID), + null, null, null)!!.use { cursor -> + assertTrue(cursor.moveToNext()) + assertEquals(parentId, cursor.getLong(0)) + } + } finally { + taskList.delete() + } } } diff --git a/src/androidTest/java/foundation/e/ical4android/AndroidTaskTest.kt b/src/androidTest/java/foundation/e/ical4android/AndroidTaskTest.kt index 42d5d3ebda3e3aff20a3552bb6ddc117c6338c94..9cf1ba9b1cc12c0d4d3321bf2679920d6da605dd 100644 --- a/src/androidTest/java/foundation/e/ical4android/AndroidTaskTest.kt +++ b/src/androidTest/java/foundation/e/ical4android/AndroidTaskTest.kt @@ -11,15 +11,14 @@ package foundation.e.ical4android import android.accounts.Account import android.content.ContentUris import android.net.Uri -import android.support.test.InstrumentationRegistry.getInstrumentation -import android.support.test.filters.MediumTest +import androidx.test.filters.MediumTest +import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import foundation.e.ical4android.impl.TestTask import foundation.e.ical4android.impl.TestTaskList import net.fortuna.ical4j.model.Date import net.fortuna.ical4j.model.TimeZone -import net.fortuna.ical4j.model.property.DtStart -import net.fortuna.ical4j.model.property.Due -import net.fortuna.ical4j.model.property.Organizer +import net.fortuna.ical4j.model.parameter.RelType +import net.fortuna.ical4j.model.property.* import org.dmfs.tasks.contract.TaskContract import org.junit.After import org.junit.Assert.* @@ -77,6 +76,15 @@ class AndroidTaskTest { task.organizer = Organizer("mailto:organizer@example.com") assertFalse(task.isAllDay()) + // extended properties + task.categories.addAll(arrayOf("Cat1", "Cat2")) + + val sibling = RelatedTo("most-fields2@example.com") + sibling.parameters.add(RelType.SIBLING) + task.relatedTo.add(sibling) + + task.unknownProperties += XProperty("X-UNKNOWN-PROP", "Unknown Value") + // add to task list val uri = TestTask(taskList!!, task).add() assertNotNull("Couldn't add task", uri) @@ -93,6 +101,10 @@ class AndroidTaskTest { assertEquals(task.description, task2.description) assertEquals(task.location, task2.location) assertEquals(task.dtStart, task2.dtStart) + + assertEquals(task.categories, task2.categories) + assertEquals(task.relatedTo, task2.relatedTo) + assertEquals(task.unknownProperties, task2.unknownProperties) } finally { testTask.delete() } diff --git a/src/androidTest/java/foundation/e/ical4android/MiscUtilsTest.kt b/src/androidTest/java/foundation/e/ical4android/MiscUtilsTest.kt index 528724de53711357c88a60a483bd78feb6076f19..cee0c4b01a2640c84952595136a8b01ba262a153 100644 --- a/src/androidTest/java/foundation/e/ical4android/MiscUtilsTest.kt +++ b/src/androidTest/java/foundation/e/ical4android/MiscUtilsTest.kt @@ -9,7 +9,9 @@ package foundation.e.ical4android import android.content.ContentValues -import android.support.test.filters.SmallTest +import android.database.MatrixCursor +import androidx.test.filters.SmallTest +import foundation.e.ical4android.MiscUtils.CursorHelper.toValues import net.fortuna.ical4j.data.CalendarBuilder import net.fortuna.ical4j.model.Date import net.fortuna.ical4j.model.DateTime @@ -65,11 +67,11 @@ class MiscUtilsTest { // DATE (without time) assertEquals(TimeZones.UTC_ID, MiscUtils.getTzId(DtStart(Date("20150101")))) - // DATE-TIME without time zone (floating time): should be UTC (because net.fortuna.ical4j.timezone.date.floating=false) - assertEquals(TimeZones.UTC_ID, MiscUtils.getTzId(DtStart(DateTime("20150101T000000")))) + // DATE-TIME without time zone (floating time): should be local time zone (because Android doesn't support floating times) + assertEquals(java.util.TimeZone.getDefault().id, MiscUtils.getTzId(DtStart(DateTime("20150101T000000")))) // DATE-TIME without time zone (UTC) - assertEquals(TimeZones.UTC_ID, MiscUtils.getTzId(DtStart(DateTime(1438607288000L)))) + assertEquals(TimeZones.UTC_ID, MiscUtils.getTzId(DtStart(DateTime(1438607288000L), true))) // DATE-TIME with time zone assertEquals(tzVienna.id, MiscUtils.getTzId(DtStart(DateTime("20150101T000000", tzVienna)))) @@ -98,6 +100,20 @@ class MiscUtilsTest { } + @Test + @SmallTest + fun testCursorToValues() { + val columns = arrayOf("col1", "col2") + val c = MatrixCursor(columns) + c.addRow(arrayOf("row1_val1", "row1_val2")) + c.moveToFirst() + val values = c.toValues() + assertEquals("row1_val1", values.getAsString("col1")) + assertEquals("row1_val2", values.getAsString("col2")) + } + + + @Suppress("unused") private class TestClass { private val s = "test" val i = 2 diff --git a/src/androidTest/java/foundation/e/ical4android/TestUtils.kt b/src/androidTest/java/foundation/e/ical4android/TestUtils.kt index 72faa70d3ac4e99c9e8975a1d9b2abed67d6a998..1888da64dc5de352f482ccfd5b1aa3d3a47a5810 100644 --- a/src/androidTest/java/foundation/e/ical4android/TestUtils.kt +++ b/src/androidTest/java/foundation/e/ical4android/TestUtils.kt @@ -8,7 +8,7 @@ package foundation.e.ical4android -import android.support.test.runner.permission.PermissionRequester +import androidx.test.runner.permission.PermissionRequester import junit.framework.AssertionFailedError object TestUtils { diff --git a/src/androidTest/java/foundation/e/ical4android/UnknownPropertyTest.kt b/src/androidTest/java/foundation/e/ical4android/UnknownPropertyTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..c973083fbf509b6ba5236aa2de5211360912733a --- /dev/null +++ b/src/androidTest/java/foundation/e/ical4android/UnknownPropertyTest.kt @@ -0,0 +1,60 @@ +package foundation.e.ical4android + +import androidx.test.filters.SmallTest +import net.fortuna.ical4j.model.parameter.Rsvp +import net.fortuna.ical4j.model.parameter.XParameter +import net.fortuna.ical4j.model.property.Attendee +import net.fortuna.ical4j.model.property.Uid +import org.json.JSONException +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class UnknownPropertyTest { + + @Test + @SmallTest + fun testFromJsonString() { + val prop = UnknownProperty.fromJsonString("[ \"UID\", \"PropValue\" ]") + assertTrue(prop is Uid) + assertEquals("UID", prop.name) + assertEquals("PropValue", prop.value) + } + + @Test + @SmallTest + 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) + assertEquals(2, prop.parameters.size()) + assertEquals("value1", prop.parameters.getParameter("x-param1").value) + assertEquals("value2", prop.parameters.getParameter("x-param2").value) + } + + @Test(expected = JSONException::class) + @SmallTest + fun testFromInvalidJsonString() { + UnknownProperty.fromJsonString("This isn't JSON") + } + + + @Test + @SmallTest + fun testToJsonString() { + val attendee = Attendee("mailto:test@test.at") + assertEquals( + "ATTENDEE:mailto:test@test.at", + attendee.toString().trim() + ) + + attendee.parameters.add(Rsvp(true)) + attendee.parameters.add(XParameter("X-My-Param", "SomeValue")) + assertEquals( + "ATTENDEE;RSVP=TRUE;X-My-Param=SomeValue:mailto:test@test.at", + attendee.toString().trim() + ) + } + +} \ No newline at end of file diff --git a/src/androidTest/java/foundation/e/ical4android/impl/TestCalendar.kt b/src/androidTest/java/foundation/e/ical4android/impl/TestCalendar.kt index fcf891bc0ba9b301f9d38cd6edcb68fc1421a5b6..07b3372e6d446d49fa49c98ffac69cf929802ed9 100644 --- a/src/androidTest/java/foundation/e/ical4android/impl/TestCalendar.kt +++ b/src/androidTest/java/foundation/e/ical4android/impl/TestCalendar.kt @@ -29,8 +29,7 @@ class TestCalendar( val values = ContentValues(3) values.put(CalendarContract.Calendars.NAME, "TestCalendar") values.put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, "ical4android Test Calendar") - values.put(CalendarContract.Calendars.ALLOWED_REMINDERS, - CalendarContract.Reminders.METHOD_DEFAULT) + values.put(CalendarContract.Calendars.ALLOWED_REMINDERS, CalendarContract.Reminders.METHOD_DEFAULT) val uri = AndroidCalendar.create(account, provider, values) TestCalendar(account, provider, ContentUris.parseId(uri)) diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index 9fce0bbf7f8d85cf6f67a4628f47a18c5d55154b..4384d1a27918128110202cdb5192fdd958268337 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -19,6 +19,6 @@ - + diff --git a/src/main/java/foundation/e/ical4android/AndroidCalendar.kt b/src/main/java/foundation/e/ical4android/AndroidCalendar.kt index 8ec0a7e932ca3e4823122120b3c02d00a195f0b0..78ceb2ea4bc994d5dceb92498afb68983295e331 100644 --- a/src/main/java/foundation/e/ical4android/AndroidCalendar.kt +++ b/src/main/java/foundation/e/ical4android/AndroidCalendar.kt @@ -12,12 +12,12 @@ import android.accounts.Account import android.content.ContentProviderClient import android.content.ContentUris import android.content.ContentValues -import android.database.DatabaseUtils import android.net.Uri -import android.provider.CalendarContract import android.provider.CalendarContract.* +import foundation.e.ical4android.MiscUtils.CursorHelper.toValues import java.io.FileNotFoundException import java.util.* +import java.util.logging.Level /** * Represents a locally stored calendar, containing [AndroidEvent]s (whose data objects are [Event]s). @@ -29,40 +29,64 @@ abstract class AndroidCalendar( val provider: ContentProviderClient, val eventFactory: AndroidEventFactory, - /** the calendar ID ([CalendarContract.Calendars._ID]) **/ + /** the calendar ID ([Calendars._ID]) **/ val id: Long ) { companion object { + /** + * Recommended initial values when creating Android [Calendars]. + */ + val calendarBaseValues = ContentValues(3) + init { + calendarBaseValues.put(Calendars.ALLOWED_AVAILABILITY, "${Events.AVAILABILITY_BUSY},${Events.AVAILABILITY_FREE}") + calendarBaseValues.put(Calendars.ALLOWED_ATTENDEE_TYPES, "${Attendees.TYPE_NONE},${Attendees.TYPE_OPTIONAL},${Attendees.TYPE_REQUIRED},${Attendees.TYPE_RESOURCE}") + calendarBaseValues.put(Calendars.ALLOWED_REMINDERS, "${Reminders.METHOD_DEFAULT},${Reminders.METHOD_ALERT},${Reminders.METHOD_EMAIL}") + } + + /** + * Creates a local (Android calendar provider) calendar. + * + * @param account account which the calendar should be assigned to + * @param provider client for Android calendar provider + * @param info initial calendar properties ([Calendars.CALENDAR_DISPLAY_NAME] etc.) – *may be modified by this method* + * + * @return [Uri] of the created calendar + * + * @throws Exception if the calendar couldn't be created + */ fun create(account: Account, provider: ContentProviderClient, info: ContentValues): Uri { info.put(Calendars.ACCOUNT_NAME, account.name) info.put(Calendars.ACCOUNT_TYPE, account.type) - // these values are generated by ical4android - info.put(Calendars.ALLOWED_AVAILABILITY, "${Events.AVAILABILITY_BUSY},${Events.AVAILABILITY_FREE},${Events.AVAILABILITY_TENTATIVE}") - info.put(Calendars.ALLOWED_ATTENDEE_TYPES, "${Attendees.TYPE_NONE},${Attendees.TYPE_OPTIONAL},${Attendees.TYPE_REQUIRED},${Attendees.TYPE_RESOURCE}") + info.putAll(calendarBaseValues) - Constants.log.info("Creating local calendar: " + info.toString()) - return provider.insert(syncAdapterURI(Calendars.CONTENT_URI, account), info) + Constants.log.info("Creating local calendar: $info") + return provider.insert(syncAdapterURI(Calendars.CONTENT_URI, account), info) ?: + throw Exception("Couldn't create calendar: provider returned null") } fun insertColors(provider: ContentProviderClient, account: Account) { provider.query(syncAdapterURI(Colors.CONTENT_URI, account), arrayOf(Colors.COLOR_KEY), null, null, null)?.use { cursor -> - if (cursor.count == EventColor.values().size) - // colors already inserted and up to date + if (cursor.count == Css3Color.values().size) + // colors already inserted and up to date return } Constants.log.info("Inserting event colors for account $account") val values = ContentValues(5) - values.put(CalendarContract.Colors.ACCOUNT_NAME, account.name) - values.put(CalendarContract.Colors.ACCOUNT_TYPE, account.type) + values.put(Colors.ACCOUNT_NAME, account.name) + values.put(Colors.ACCOUNT_TYPE, account.type) values.put(Colors.COLOR_TYPE, Colors.TYPE_EVENT) - for (color in EventColor.values()) { + for (color in Css3Color.values()) { values.put(Colors.COLOR_KEY, color.name) - values.put(Colors.COLOR, color.rgba) - provider.insert(syncAdapterURI(Colors.CONTENT_URI, account), values) + values.put(Colors.COLOR, color.argb) + try { + provider.insert(syncAdapterURI(Colors.CONTENT_URI, account), values) + } catch(e: Exception) { + Constants.log.log(Level.WARNING, "Couldn't insert event color: ${color.name}", e) + } } } @@ -83,8 +107,8 @@ abstract class AndroidCalendar( } fun> findByID(account: Account, provider: ContentProviderClient, factory: AndroidCalendarFactory, id: Long): T { - val iterCalendars = CalendarContract.CalendarEntity.newEntityIterator( - provider.query(syncAdapterURI(ContentUris.withAppendedId(CalendarContract.CalendarEntity.CONTENT_URI, id), account), null, null, null, null) + val iterCalendars = CalendarEntity.newEntityIterator( + provider.query(syncAdapterURI(ContentUris.withAppendedId(CalendarEntity.CONTENT_URI, id), account), null, null, null, null) ) try { if (iterCalendars.hasNext()) { @@ -100,8 +124,8 @@ abstract class AndroidCalendar( } fun> find(account: Account, provider: ContentProviderClient, factory: AndroidCalendarFactory, where: String?, whereArgs: Array?): List { - val iterCalendars = CalendarContract.CalendarEntity.newEntityIterator( - provider.query(syncAdapterURI(CalendarContract.CalendarEntity.CONTENT_URI, account), null, where, whereArgs, null) + val iterCalendars = CalendarEntity.newEntityIterator( + provider.query(syncAdapterURI(CalendarEntity.CONTENT_URI, account), null, where, whereArgs, null) ) try { val calendars = LinkedList() @@ -120,7 +144,7 @@ abstract class AndroidCalendar( fun syncAdapterURI(uri: Uri, account: Account) = uri.buildUpon() .appendQueryParameter(Calendars.ACCOUNT_NAME, account.name) .appendQueryParameter(Calendars.ACCOUNT_TYPE, account.type) - .appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true") + .appendQueryParameter(CALLER_IS_SYNCADAPTER, "true") .build()!! } @@ -150,21 +174,18 @@ abstract class AndroidCalendar( /** * Queries events from this calendar. Adds a WHERE clause that restricts the * query to [Events.CALENDAR_ID] = [id]. - * @param where selection - * @param whereArgs arguments for selection + * @param _where selection + * @param _whereArgs arguments for selection * @return events from this calendar which match the selection */ - fun queryEvents(where: String? = null, whereArgs: Array? = null): List { - val where = "(${where ?: "1"}) AND " + Events.CALENDAR_ID + "=?" - val whereArgs = (whereArgs ?: arrayOf()) + id.toString() + fun queryEvents(_where: String? = null, _whereArgs: Array? = null): List { + val where = "(${_where ?: "1"}) AND " + Events.CALENDAR_ID + "=?" + val whereArgs = (_whereArgs ?: arrayOf()) + id.toString() val events = LinkedList() provider.query(eventsSyncURI(), null, where, whereArgs, null)?.use { cursor -> - while (cursor.moveToNext()) { - val values = ContentValues(cursor.columnCount) - DatabaseUtils.cursorRowToContentValues(cursor, values) - events += eventFactory.fromProvider(this, values) - } + while (cursor.moveToNext()) + events += eventFactory.fromProvider(this, cursor.toValues()) } return events } @@ -174,7 +195,7 @@ abstract class AndroidCalendar( fun syncAdapterURI(uri: Uri) = uri.buildUpon() - .appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true") + .appendQueryParameter(CALLER_IS_SYNCADAPTER, "true") .appendQueryParameter(Calendars.ACCOUNT_NAME, account.name) .appendQueryParameter(Calendars.ACCOUNT_TYPE, account.type) .build()!! diff --git a/src/main/java/foundation/e/ical4android/AndroidEvent.kt b/src/main/java/foundation/e/ical4android/AndroidEvent.kt index 52eb37bfc07f9f44188d79aedf7de75151d5a671..400835a0d5a5bbc34f6ac6437f9827d3bbae47f5 100644 --- a/src/main/java/foundation/e/ical4android/AndroidEvent.kt +++ b/src/main/java/foundation/e/ical4android/AndroidEvent.kt @@ -13,12 +13,12 @@ import android.content.ContentProviderOperation.Builder import android.content.ContentUris import android.content.ContentValues import android.content.EntityIterator -import android.database.DatabaseUtils import android.net.Uri import android.os.RemoteException -import android.provider.CalendarContract import android.provider.CalendarContract.* import android.util.Base64 +import android.util.Patterns +import foundation.e.ical4android.MiscUtils.CursorHelper.toValues import net.fortuna.ical4j.model.* import net.fortuna.ical4j.model.Date import net.fortuna.ical4j.model.TimeZone @@ -26,7 +26,9 @@ 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 java.io.* +import java.io.ByteArrayInputStream +import java.io.FileNotFoundException +import java.io.ObjectInputStream import java.net.URI import java.net.URISyntaxException import java.text.ParseException @@ -49,9 +51,28 @@ 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" - const val MAX_UNKNOWN_PROPERTY_SIZE = 25000 + + @Deprecated("New content item MIME type", ReplaceWith("UnknownProperty.CONTENT_ITEM_TYPE")) + const val EXT_UNKNOWN_PROPERTY2 = "unknown-property.v2" + + /** + * VEVENT CATEGORIES will be stored as an extended property with this [ExtendedProperties.NAME]. + * + * The [ExtendedProperties.VALUE] format is the same as used by the AOSP Exchange ActiveSync adapter: + * the category values are stored as list, separated by [EXT_CATEGORIES_SEPARATOR]. (If a category + * value contains [EXT_CATEGORIES_SEPARATOR], [EXT_CATEGORIES_SEPARATOR] will be dropped.) + * + * Example: `Cat1\Cat2` + */ + const val EXT_CATEGORIES = "categories" + const val EXT_CATEGORIES_SEPARATOR = '\\' + + /** + * EMAIL parameter name (as used for ORGANIZER). Not declared in ical4j Parameters class yet. + */ + private const val PARAMETER_EMAIL = "EMAIL" } @@ -90,9 +111,9 @@ abstract class AndroidEvent( var iterEvents: EntityIterator? = null try { - iterEvents = CalendarContract.EventsEntity.newEntityIterator( + iterEvents = EventsEntity.newEntityIterator( calendar.provider.query( - calendar.syncAdapterURI(ContentUris.withAppendedId(CalendarContract.EventsEntity.CONTENT_URI, id)), + calendar.syncAdapterURI(ContentUris.withAppendedId(EventsEntity.CONTENT_URI, id)), null, null, null, null), calendar.provider ) @@ -101,14 +122,16 @@ abstract class AndroidEvent( field = event val e = iterEvents.next() - populateEvent(e.entityValues) + populateEvent(MiscUtils.removeEmptyStrings(e.entityValues)) - for (subValue in e.subValues) + for (subValue in e.subValues) { + val subValues = MiscUtils.removeEmptyStrings(subValue.values) when (subValue.uri) { - Attendees.CONTENT_URI -> populateAttendee(subValue.values) - Reminders.CONTENT_URI -> populateReminder(subValue.values) - CalendarContract.ExtendedProperties.CONTENT_URI -> populateExtended(subValue.values) + Attendees.CONTENT_URI -> populateAttendee(subValues) + Reminders.CONTENT_URI -> populateReminder(subValues) + ExtendedProperties.CONTENT_URI -> populateExtended(subValues) } + } populateExceptions() useRetainedClassification() @@ -133,10 +156,8 @@ abstract class AndroidEvent( * @param row values of an [Events] row, as returned by the calendar provider */ protected open fun populateEvent(row: ContentValues) { - val event = requireNotNull(event) - Constants.log.log(Level.FINE, "Read event entity from calender provider", row) - MiscUtils.removeEmptyStrings(row) + val event = requireNotNull(event) event.summary = row.getAsString(Events.TITLE) event.location = row.getAsString(Events.EVENT_LOCATION) @@ -144,7 +165,7 @@ abstract class AndroidEvent( row.getAsString(Events.EVENT_COLOR_KEY)?.let { name -> try { - event.color = EventColor.valueOf(name) + event.color = Css3Color.valueOf(name) } catch(e: IllegalArgumentException) { Constants.log.warning("Ignoring unknown color $name from Calendar Provider") } @@ -233,9 +254,7 @@ abstract class AndroidEvent( // exceptions from recurring events row.getAsLong(Events.ORIGINAL_INSTANCE_TIME)?.let { originalInstanceTime -> - var originalAllDay = false - row.getAsInteger(Events.ORIGINAL_ALL_DAY)?.let { originalAllDay = it != 0 } - + val originalAllDay = (row.getAsInteger(Events.ORIGINAL_ALL_DAY) ?: 0) != 0 val originalDate = if (originalAllDay) Date(originalInstanceTime) else DateTime(originalInstanceTime) @@ -247,7 +266,6 @@ abstract class AndroidEvent( protected open fun populateAttendee(row: ContentValues) { Constants.log.log(Level.FINE, "Read event attendee from calender provider", row) - MiscUtils.removeEmptyStrings(row) try { val attendee: Attendee @@ -258,7 +276,7 @@ abstract class AndroidEvent( if (idNS != null || id != null) { // attendee identified by namespace and ID attendee = Attendee(URI(idNS, id, null)) - email?.let { attendee.parameters.add(ICalendar.Email(it)) } + email?.let { attendee.parameters.add(Email(it)) } } else // attendee identified by email address attendee = Attendee(URI("mailto", email, null)) @@ -271,8 +289,7 @@ abstract class AndroidEvent( params.add(if (type == Attendees.TYPE_RESOURCE) CuType.RESOURCE else CuType.INDIVIDUAL) // role - val relationship = row.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP) - when (relationship) { + when (row.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP)) { Attendees.RELATIONSHIP_ORGANIZER, Attendees.RELATIONSHIP_ATTENDEE, Attendees.RELATIONSHIP_PERFORMER, @@ -300,39 +317,63 @@ abstract class AndroidEvent( protected open fun populateReminder(row: ContentValues) { Constants.log.log(Level.FINE, "Read event reminder from calender provider", row) - val event = requireNotNull(event) + val alarm = VAlarm(Dur(0, 0, -row.getAsInteger(Reminders.MINUTES), 0)) val props = alarm.properties - props += when (row.getAsInteger(Reminders.METHOD)) { - Reminders.METHOD_ALARM, - Reminders.METHOD_ALERT -> - Action.DISPLAY - Reminders.METHOD_EMAIL, - Reminders.METHOD_SMS -> - Action.EMAIL - else -> - // show alarm by default - Action.DISPLAY + when (row.getAsInteger(Reminders.METHOD)) { + Reminders.METHOD_EMAIL -> { + val accountName = calendar.account.name + if (Patterns.EMAIL_ADDRESS.matcher(accountName).matches()) { + props += Action.EMAIL + // ACTION:EMAIL requires SUMMARY, DESCRIPTION, ATTENDEE + props += Summary(event.summary) + props += Description(event.description ?: event.summary) + // Android doesn't allow to save email reminder recipients, so we always use the + // account name (should be account owner's email address) + props += Attendee(URI("mailto", calendar.account.name, null)) + } else { + Constants.log.warning("Account name is not an email address; changing EMAIL reminder to DISPLAY") + props += Action.DISPLAY + props += Description(event.summary) + } + } + + // default: set ACTION:DISPLAY (requires DESCRIPTION) + else -> { + props += Action.DISPLAY + props += Description(event.summary) + } } - props += Description(event.summary) event.alarms += alarm } protected open fun populateExtended(row: ContentValues) { - Constants.log.log(Level.FINE, "Read extended property from calender provider", row.getAsString(ExtendedProperties.NAME)) + val name = row.getAsString(ExtendedProperties.NAME) + Constants.log.log(Level.FINE, "Read extended property from calender provider (name=$name)") + val event = requireNotNull(event) - if (row.getAsString(ExtendedProperties.NAME) == EXT_UNKNOWN_PROPERTY) { - // de-serialize unknown property - val stream = ByteArrayInputStream(Base64.decode(row.getAsString(ExtendedProperties.VALUE), Base64.NO_WRAP)) - try { - ObjectInputStream(stream).use { - event!!.unknownProperties += it.readObject() as Property + try { + when (row.getAsString(ExtendedProperties.NAME)) { + EXT_CATEGORIES -> { + val rawCategories = row.getAsString(ExtendedProperties.VALUE) + event.categories += rawCategories.split(EXT_CATEGORIES_SEPARATOR) + } + + EXT_UNKNOWN_PROPERTY -> { + // 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 + } } - } catch(e: Exception) { - Constants.log.log(Level.WARNING, "Couldn't de-serialize unknown property", e) + + 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) } } @@ -344,8 +385,7 @@ abstract class AndroidEvent( null, Events.ORIGINAL_ID + "=?", arrayOf(id.toString()), null)?.use { c -> while (c.moveToNext()) { - val values = ContentValues(c.columnCount) - DatabaseUtils.cursorRowToContentValues(c, values) + val values = c.toValues(true) try { val exception = calendar.eventFactory.fromProvider(calendar, values) @@ -402,6 +442,8 @@ abstract class AndroidEvent( // add unknown properties retainClassification() + if (event.categories.isNotEmpty()) + insertCategories(batch, idxEvent) event.unknownProperties.forEach { insertUnknownProperty(batch, idxEvent, it) } // add exceptions @@ -581,7 +623,7 @@ abstract class AndroidEvent( email = if (uri.scheme.equals("mailto", true)) uri.schemeSpecificPart else { - val emailParam = organizer.getParameter(ICalendar.Email.PARAMETER_NAME) as ICalendar.Email? + val emailParam = organizer.getParameter(PARAMETER_EMAIL) as? Email emailParam?.value } if (email != null) @@ -612,11 +654,13 @@ abstract class AndroidEvent( protected open fun insertReminder(batch: BatchOperation, idxEvent: Int, alarm: VAlarm) { val builder = ContentProviderOperation.newInsert(calendar.syncAdapterURI(Reminders.CONTENT_URI)) - val action = alarm.action - val method = when (action?.value) { + val method = when (alarm.action?.value?.toUpperCase(Locale.US)) { Action.DISPLAY.value, Action.AUDIO.value -> Reminders.METHOD_ALERT + + // Note: The calendar provider doesn't support saving specific attendees for email reminders. Action.EMAIL.value -> Reminders.METHOD_EMAIL + else -> Reminders.METHOD_DEFAULT } @@ -639,7 +683,7 @@ abstract class AndroidEvent( // attendee identified by other URI builder .withValue(Attendees.ATTENDEE_ID_NAMESPACE, member.scheme) .withValue(Attendees.ATTENDEE_IDENTITY, member.schemeSpecificPart) - (attendee.getParameter(ICalendar.Email.PARAMETER_NAME) as ICalendar.Email?)?.let { email -> + (attendee.getParameter(PARAMETER_EMAIL) as? Email)?.let { email -> builder.withValue(Attendees.ATTENDEE_EMAIL, email.value) } } @@ -649,13 +693,13 @@ abstract class AndroidEvent( } var type = Attendees.TYPE_NONE - val cutype = attendee.getParameter(Parameter.CUTYPE) as CuType? + val cutype = attendee.getParameter(Parameter.CUTYPE) as? CuType if (cutype in arrayOf(CuType.RESOURCE, CuType.ROOM)) // "attendee" is a (physical) resource type = Attendees.TYPE_RESOURCE else { // attendee is not a (physical) resource - val role = attendee.getParameter(Parameter.ROLE) as Role? + val role = attendee.getParameter(Parameter.ROLE) as? Role val relationship: Int if (role == Role.CHAIR) relationship = Attendees.RELATIONSHIP_ORGANIZER @@ -669,8 +713,7 @@ abstract class AndroidEvent( builder.withValue(Attendees.ATTENDEE_RELATIONSHIP, relationship) } - val partStat = attendee.getParameter(Parameter.PARTSTAT) as PartStat? - val status = when(partStat) { + val status = when(attendee.getParameter(Parameter.PARTSTAT) as? PartStat) { null, PartStat.NEEDS_ACTION -> Attendees.ATTENDEE_STATUS_INVITED PartStat.ACCEPTED -> Attendees.ATTENDEE_STATUS_ACCEPTED @@ -686,26 +729,30 @@ abstract class AndroidEvent( batch.enqueue(BatchOperation.Operation(builder, Attendees.EVENT_ID, idxEvent)) } - protected open fun insertUnknownProperty(batch: BatchOperation, idxEvent: Int, property: Property) { - val baos = ByteArrayOutputStream() - try { - ObjectOutputStream(baos).use { oos -> - oos.writeObject(property) + protected open fun insertCategories(batch: BatchOperation, idxEvent: Int) { + val rawCategories = event!!.categories + .map { it.filter { it != EXT_CATEGORIES_SEPARATOR } } // drop backslashes + .joinToString(EXT_CATEGORIES_SEPARATOR.toString()) // concatenate, separate by backslash + val builder = ContentProviderOperation.newInsert(calendar.syncAdapterURI(ExtendedProperties.CONTENT_URI)) + .withValue(ExtendedProperties.NAME, EXT_CATEGORIES) + .withValue(ExtendedProperties.VALUE, rawCategories) - if (baos.size() > MAX_UNKNOWN_PROPERTY_SIZE) { - Constants.log.warning("Ignoring unknown property with ${baos.size()} octets") - return - } - - val builder = ContentProviderOperation.newInsert(calendar.syncAdapterURI(ExtendedProperties.CONTENT_URI)) - builder .withValue(ExtendedProperties.NAME, EXT_UNKNOWN_PROPERTY) - .withValue(ExtendedProperties.VALUE, Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP)) + Constants.log.log(Level.FINE, "Built categories", builder.build()) + batch.enqueue(BatchOperation.Operation(builder, ExtendedProperties.EVENT_ID, idxEvent)) + } - batch.enqueue(BatchOperation.Operation(builder, ExtendedProperties.EVENT_ID, idxEvent)) - } - } catch(e: IOException) { - Constants.log.log(Level.WARNING, "Couldn't serialize unknown property", e) + protected open fun insertUnknownProperty(batch: BatchOperation, idxEvent: Int, property: Property) { + 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)) + .withValue(ExtendedProperties.NAME, UnknownProperty.CONTENT_ITEM_TYPE) + .withValue(ExtendedProperties.VALUE, UnknownProperty.toJsonString(property)) + + Constants.log.log(Level.FINE, "Built unknown property: ${property.name}") + batch.enqueue(BatchOperation.Operation(builder, ExtendedProperties.EVENT_ID, idxEvent)) } private fun useRetainedClassification() { @@ -734,7 +781,7 @@ abstract class AndroidEvent( return calendar.syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, id)) } - override fun toString() = MiscUtils.reflectionToString(this) + } diff --git a/src/main/java/foundation/e/ical4android/AndroidTask.kt b/src/main/java/foundation/e/ical4android/AndroidTask.kt index 77df1b416257ab531443c8b4e87fd389526a5ed7..bc03837500a57297e96a2409bb3d4f1b1acf0d1d 100644 --- a/src/main/java/foundation/e/ical4android/AndroidTask.kt +++ b/src/main/java/foundation/e/ical4android/AndroidTask.kt @@ -12,18 +12,24 @@ import android.content.ContentProviderOperation import android.content.ContentProviderOperation.Builder import android.content.ContentUris import android.content.ContentValues -import android.database.DatabaseUtils import android.net.Uri import android.os.RemoteException +import foundation.e.ical4android.MiscUtils.CursorHelper.toValues +import net.fortuna.ical4j.model.* import net.fortuna.ical4j.model.Date -import net.fortuna.ical4j.model.DateTime -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.RelType +import net.fortuna.ical4j.model.parameter.Related import net.fortuna.ical4j.model.property.* -import org.dmfs.tasks.contract.TaskContract.Tasks +import org.dmfs.tasks.contract.TaskContract.* +import org.dmfs.tasks.contract.TaskContract.Properties +import org.dmfs.tasks.contract.TaskContract.Property.* import java.io.FileNotFoundException import java.net.URI import java.net.URISyntaxException import java.util.* +import java.util.TimeZone import java.util.logging.Level /** @@ -40,6 +46,10 @@ abstract class AndroidTask( val taskList: AndroidTaskList ) { + companion object { + const val UNKNOWN_PROPERTY_DATA = Properties.DATA0 + } + var id: Long? = null @@ -66,11 +76,42 @@ abstract class AndroidTask( val id = requireNotNull(id) task = Task() - taskList.provider.client.query(taskSyncURI(), null, null, null, null)?.use { cursor -> + val client = taskList.provider.client + client.query(taskSyncURI(true), null, null, null, null)?.use { cursor -> if (cursor.moveToFirst()) { - val values = ContentValues(cursor.columnCount) - DatabaseUtils.cursorRowToContentValues(cursor, values) + val values = cursor.toValues(true) + Constants.log.log(Level.FINER, "Found task", values) populateTask(values) + + if (values.containsKey(Properties.PROPERTY_ID)) { + // process the first property, which is combined with the task row + populateProperty(values) + + while (cursor.moveToNext()) { + // process the other properties + populateProperty(cursor.toValues(true)) + } + } + + // Special case: parent_id set, but no matching parent Relation row (like given by aCalendar+) + val relatedToList = task!!.relatedTo + values.getAsLong(Tasks.PARENT_ID)?.let { parentId -> + val hasParentRelation = relatedToList.any { relatedTo -> + val relatedType = relatedTo.getParameter(Parameter.RELTYPE) + relatedType == RelType.PARENT || relatedType == null /* RelType.PARENT is the default value */ + } + if (!hasParentRelation) { + // get UID of parent task + val parentContentUri = ContentUris.withAppendedId(taskList.tasksSyncUri(), parentId) + client.query(parentContentUri, arrayOf(Tasks._UID), null, null, null)?.use { cursor -> + if (cursor.moveToNext()) { + // add RelatedTo for parent task + relatedToList += RelatedTo(cursor.getString(0)) + } + } + } + } + return task } } @@ -80,8 +121,6 @@ abstract class AndroidTask( protected open fun populateTask(values: ContentValues) { val task = requireNotNull(task) - MiscUtils.removeEmptyStrings(values) - task.uid = values.getAsString(Tasks._UID) task.sequence = values.getAsInteger(Tasks.SYNC_VERSION) task.summary = values.getAsString(Tasks.TITLE) @@ -161,6 +200,74 @@ abstract class AndroidTask( values.getAsString(Tasks.RRULE)?.let { task.rRule = RRule(it) } } + 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) + Relation.CONTENT_ITEM_TYPE -> + populateRelatedTo(row) + UnknownProperty.CONTENT_ITEM_TYPE -> + task.unknownProperties += UnknownProperty.fromJsonString(row.getAsString(UNKNOWN_PROPERTY_DATA)) + else -> + Constants.log.warning("Found unknown property of type $type") + } + } + + protected open fun populateAlarm(row: ContentValues) { + val task = requireNotNull(task) + val props = PropertyList() + + val trigger = Trigger(Dur(0, 0, -row.getAsInteger(Alarm.MINUTES_BEFORE), 0)) + 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) + } + + protected open fun populateRelatedTo(row: ContentValues) { + val uid = row.getAsString(Relation.RELATED_UID) + if (uid == null) { + Constants.log.warning("Task relation doesn't refer to same task list; can't be synchronized") + return + } + + val relatedTo = RelatedTo(uid) + + // add relation type as reltypeparam + relatedTo.parameters.add(when (row.getAsInteger(Relation.RELATED_TYPE)) { + Relation.RELTYPE_CHILD -> + RelType.CHILD + Relation.RELTYPE_SIBLING -> + RelType.SIBLING + else /* Relation.RELTYPE_PARENT, default value */ -> + RelType.PARENT + }) + + requireNotNull(task).relatedTo.add(relatedTo) + } + fun add(): Uri { val batch = BatchOperation(taskList.provider.client) @@ -169,8 +276,13 @@ abstract class AndroidTask( batch.enqueue(BatchOperation.Operation(builder)) batch.commit() + // TODO use backref mechanism so that only one commit is required for the whole task val result = batch.getResult(0) ?: throw CalendarStorageException("Empty result from provider when adding a task") id = ContentUris.parseId(result.uri) + + insertProperties(batch) + batch.commit() + return result.uri } @@ -182,10 +294,103 @@ abstract class AndroidTask( val builder = ContentProviderOperation.newUpdate(uri) buildTask(builder, true) batch.enqueue(BatchOperation.Operation(builder)) + + val deleteProperties = ContentProviderOperation.newDelete(taskList.tasksPropertiesSyncUri()) + .withSelection("${Properties.TASK_ID}=?", arrayOf(id.toString())) + batch.enqueue(BatchOperation.Operation(deleteProperties)) + insertProperties(batch) + batch.commit() return uri } + protected open fun insertProperties(batch: BatchOperation) { + insertAlarms(batch) + insertCategories(batch) + insertRelatedTo(batch) + insertUnknownProperties(batch) + } + + protected open fun insertAlarms(batch: BatchOperation) { + for (alarm in requireNotNull(task).alarms) { + val alarmRef = when (alarm.trigger.getParameter(Parameter.RELATED)) { + 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?.toUpperCase(Locale.US)) { + 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 = ContentProviderOperation.newInsert(taskList.tasksPropertiesSyncUri()) + .withValue(Alarm.TASK_ID, id) + .withValue(Alarm.MIMETYPE, Alarm.CONTENT_ITEM_TYPE) + .withValue(Alarm.MINUTES_BEFORE, ICalendar.alarmMinBefore(alarm)) + .withValue(Alarm.REFERENCE, alarmRef) + .withValue(Alarm.MESSAGE, alarm.description?.value ?: alarm.summary) + .withValue(Alarm.ALARM_TYPE, alarmType) + + Constants.log.log(Level.FINE, "Inserting alarm", builder.build()) + batch.enqueue(BatchOperation.Operation(builder)) + } + } + + protected open fun insertCategories(batch: BatchOperation) { + for (category in requireNotNull(task).categories) { + val builder = ContentProviderOperation.newInsert(taskList.tasksPropertiesSyncUri()) + .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()) + batch.enqueue(BatchOperation.Operation(builder)) + } + } + + protected open fun insertRelatedTo(batch: BatchOperation) { + for (relatedTo in requireNotNull(task).relatedTo) { + val relType = when ((relatedTo.getParameter(Parameter.RELTYPE) as RelType?)) { + RelType.CHILD -> + Relation.RELTYPE_CHILD + RelType.SIBLING -> + Relation.RELTYPE_SIBLING + else /* RelType.PARENT, default value */ -> + Relation.RELTYPE_PARENT + } + val builder = ContentProviderOperation.newInsert(taskList.tasksPropertiesSyncUri()) + .withValue(Relation.TASK_ID, id) + .withValue(Relation.MIMETYPE, Relation.CONTENT_ITEM_TYPE) + .withValue(Relation.RELATED_UID, relatedTo.value) + .withValue(Relation.RELATED_TYPE, relType) + Constants.log.log(Level.FINE, "Inserting relation", builder.build()) + batch.enqueue(BatchOperation.Operation(builder)) + } + } + + protected open fun insertUnknownProperties(batch: BatchOperation) { + for (property in requireNotNull(task).unknownProperties) { + if (property.value.length > UnknownProperty.MAX_UNKNOWN_PROPERTY_SIZE) { + Constants.log.warning("Ignoring unknown property with ${property.value.length} octets (too long)") + 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) @@ -199,24 +404,26 @@ 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) + // parent_id will be re-calculated when the relation row is inserted (if there is any) + .withValue(Tasks.PARENT_ID, null) + var organizer: String? = null task.organizer?.let { try { val uri = URI(it.value) - if (uri.scheme == "mailto") + if (uri.scheme.equals("mailto", true)) organizer = uri.schemeSpecificPart else Constants.log.log(Level.WARNING, "Found non-mailto ORGANIZER URI, ignoring", uri) @@ -260,15 +467,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()) } @@ -276,7 +482,7 @@ abstract class AndroidTask( fun getTimeZone(): TimeZone { val task = requireNotNull(task) - var tz: java.util.TimeZone? = null + var tz: TimeZone? = null task.dtStart?.timeZone?.let { tz = it } tz = tz ?: task.due?.timeZone @@ -284,9 +490,9 @@ abstract class AndroidTask( return tz ?: TimeZone.getDefault() } - protected fun taskSyncURI(): Uri { + protected fun taskSyncURI(loadProperties: Boolean = false): Uri { val id = requireNotNull(id) - return ContentUris.withAppendedId(taskList.tasksSyncUri(), id) + return ContentUris.withAppendedId(taskList.tasksSyncUri(loadProperties), id) } diff --git a/src/main/java/foundation/e/ical4android/AndroidTaskList.kt b/src/main/java/foundation/e/ical4android/AndroidTaskList.kt index d7b73dc5e806f62ccc1a018d8f9c30dd63ec649e..2e760bd640c3e154041e971e36f4403df5f081d4 100644 --- a/src/main/java/foundation/e/ical4android/AndroidTaskList.kt +++ b/src/main/java/foundation/e/ical4android/AndroidTaskList.kt @@ -9,17 +9,20 @@ package foundation.e.ical4android import android.accounts.Account +import android.content.ContentProviderOperation import android.content.ContentUris import android.content.ContentValues import android.content.Context -import android.database.DatabaseUtils import android.net.Uri +import foundation.e.ical4android.MiscUtils.CursorHelper.toValues import org.dmfs.tasks.contract.TaskContract +import org.dmfs.tasks.contract.TaskContract.Property.Relation import org.dmfs.tasks.contract.TaskContract.TaskLists import org.dmfs.tasks.contract.TaskContract.Tasks import java.io.FileNotFoundException import java.util.* + /** * Represents a locally stored task list, containing AndroidTasks (whose data objects are Tasks). * Communicates with third-party content providers to store the tasks. @@ -37,8 +40,9 @@ abstract class AndroidTaskList( /** * Acquires a [android.content.ContentProviderClient] for a supported task provider. If multiple providers are * available, a pre-defined priority list is taken into account. + * * @return A [TaskProvider], or null if task storage is not available/accessible. - * Caller is responsible for calling release()! + * Caller is responsible for calling [TaskProvider.close]! */ fun acquireTaskProvider(context: Context): TaskProvider? { val byPriority = arrayOf( @@ -58,17 +62,16 @@ abstract class AndroidTaskList( info.put(TaskContract.ACCOUNT_TYPE, account.type) info.put(TaskLists.ACCESS_LEVEL, 0) - Constants.log.info("Creating local task list: " + info.toString()) - return provider.client.insert(TaskProvider.syncAdapterUri(provider.taskListsUri(), account), info) + Constants.log.info("Creating local task list: $info") + return provider.client.insert(TaskProvider.syncAdapterUri(provider.taskListsUri(), account), info) ?: + throw CalendarStorageException("Couldn't create task list (empty result from provider)") } fun> findByID(account: Account, provider: TaskProvider, factory: AndroidTaskListFactory, id: Long): T { provider.client.query(TaskProvider.syncAdapterUri(ContentUris.withAppendedId(provider.taskListsUri(), id), account), null, null, null, null)?.use { cursor -> if (cursor.moveToNext()) { val taskList = factory.newInstance(account, provider, id) - val values = ContentValues(cursor.columnCount) - DatabaseUtils.cursorRowToContentValues(cursor, values) - taskList.populate(values) + taskList.populate(cursor.toValues()) return taskList } } @@ -79,8 +82,7 @@ abstract class AndroidTaskList( val taskLists = LinkedList() provider.client.query(TaskProvider.syncAdapterUri(provider.taskListsUri(), account), null, where, whereArgs, null)?.use { cursor -> while (cursor.moveToNext()) { - val values = ContentValues(cursor.columnCount) - DatabaseUtils.cursorRowToContentValues(cursor, values) + val values = cursor.toValues() val taskList = factory.newInstance(account, provider, values.getAsLong(TaskLists._ID)) taskList.populate(values) taskLists += taskList @@ -109,28 +111,67 @@ abstract class AndroidTaskList( fun update(info: ContentValues) = provider.client.update(taskListSyncUri(), info, null, null) fun delete() = provider.client.delete(taskListSyncUri(), null, null) + /** + * When tasks are added or updated, they may refer to related tasks by UID ([Relation.RELATED_UID]). + * However, those related tasks may not be available (for instance, because they have not been + * synchronized yet), so that the tasks provider can't establish the actual relation (= set + * [Relation.TASK_ID]) in the database. + * + * As soon as such a related task is added, OpenTasks updates the [Relation.RELATED_ID], + * but it does *not* update [Tasks.PARENT_ID] of the parent task: + * https://github.com/dmfs/opentasks/issues/877 + * + * This method shall be called after all tasks have been synchronized. It touches + * + * - all [Relation] rows + * - with [Relation.RELATED_ID] (→ related task is already synchronized) + * - of tasks without [Tasks.PARENT_ID] (→ only touch relevant rows) + * + * so that missing [Tasks.PARENT_ID] fields are updated. + * + * @return number of touched [Relation] rows + */ + fun touchRelations(): Int { + Constants.log.fine("Touching relations to set parent_id") + val batchOperation = BatchOperation(provider.client) + provider.client.query(tasksSyncUri(true), null, + "${Tasks.LIST_ID}=? AND ${Tasks.PARENT_ID} IS NULL AND ${Relation.MIMETYPE}=? AND ${Relation.RELATED_ID} IS NOT NULL", + arrayOf(id.toString(), Relation.CONTENT_ITEM_TYPE), + null, null)?.use { cursor -> + while (cursor.moveToNext()) { + val values = cursor.toValues() + val id = values.getAsLong(Relation.PROPERTY_ID) + val propertyContentUri = ContentUris.withAppendedId(tasksPropertiesSyncUri(), id) + batchOperation.enqueue(BatchOperation.Operation( + ContentProviderOperation.newUpdate(propertyContentUri) + .withValue(Relation.RELATED_ID, values.getAsLong(Relation.RELATED_ID)) + )) + } + } + return batchOperation.commit() + } + /** * Queries tasks from this task list. Adds a WHERE clause that restricts the * query to [Tasks.LIST_ID] = [id]. - * @param where selection - * @param whereArgs arguments for selection + * + * @param _where selection + * @param _whereArgs arguments for selection + * * @return events from this task list which match the selection */ - fun queryTasks(where: String? = null, whereArgs: Array? = null): List { - val where = "(${where ?: "1"}) AND ${Tasks.LIST_ID}=?" - val whereArgs = (whereArgs ?: arrayOf()) + id.toString() + fun queryTasks(_where: String? = null, _whereArgs: Array? = null): List { + val where = "(${_where ?: "1"}) AND ${Tasks.LIST_ID}=?" + val whereArgs = (_whereArgs ?: arrayOf()) + id.toString() val tasks = LinkedList() provider.client.query( tasksSyncUri(), null, where, whereArgs, null)?.use { cursor -> - while (cursor.moveToNext()) { - val values = ContentValues(cursor.columnCount) - DatabaseUtils.cursorRowToContentValues(cursor, values) - tasks += taskFactory.fromProvider(this, values) - } + while (cursor.moveToNext()) + tasks += taskFactory.fromProvider(this, cursor.toValues()) } return tasks } @@ -140,6 +181,15 @@ abstract class AndroidTaskList( fun taskListSyncUri() = TaskProvider.syncAdapterUri(ContentUris.withAppendedId(provider.taskListsUri(), id), account) - fun tasksSyncUri() = TaskProvider.syncAdapterUri(provider.tasksUri(), account) + fun tasksSyncUri(loadProperties: Boolean = false): Uri { + val uri = TaskProvider.syncAdapterUri(provider.tasksUri(), account) + return if (loadProperties) + uri .buildUpon() + .appendQueryParameter(TaskContract.LOAD_PROPERTIES, "1") + .build() + else + uri + } + fun tasksPropertiesSyncUri() = TaskProvider.syncAdapterUri(provider.propertiesUri(), account) } diff --git a/src/main/java/foundation/e/ical4android/Constants.kt b/src/main/java/foundation/e/ical4android/Constants.kt index 375c6964c85e91e674ad5d8919605450b4a0e18c..a4cf994f601b0f74aa8eb4c565de12e3d1c4be10 100644 --- a/src/main/java/foundation/e/ical4android/Constants.kt +++ b/src/main/java/foundation/e/ical4android/Constants.kt @@ -12,7 +12,7 @@ import java.util.logging.Logger object Constants { - val log = Logger.getLogger("ical4android")!! + val log = Logger.getLogger("ical4android") const val ical4jVersion = BuildConfig.version_ical4j diff --git a/src/main/java/foundation/e/ical4android/EventColor.kt b/src/main/java/foundation/e/ical4android/Css3Color.kt similarity index 84% rename from src/main/java/foundation/e/ical4android/EventColor.kt rename to src/main/java/foundation/e/ical4android/Css3Color.kt index a3bcabac5d3bd00d41b5137a59e40c31f4a427ae..f849bb9c3fed471e77a884a215c9c93256ff7eff 100644 --- a/src/main/java/foundation/e/ical4android/EventColor.kt +++ b/src/main/java/foundation/e/ical4android/Css3Color.kt @@ -8,11 +8,15 @@ package foundation.e.ical4android +import kotlin.math.sqrt + /** * Represents an RGBA COLOR value, as specified in https://tools.ietf.org/html/rfc7986#section-5.9 + * + * @property argb ARGB color value (0xAARRGGBB), alpha is 0xFF for all values */ -@Suppress("EnumEntryName") -enum class EventColor(val rgba: Int) { +@Suppress("EnumEntryName", "SpellCheckingInspection") +enum class Css3Color(val argb: Int) { // values taken from https://www.w3.org/TR/2011/REC-css3-color-20110607/#svg-color aliceblue(0xfff0f8ff.toInt()), antiquewhite(0xfffaebd7.toInt()), @@ -166,13 +170,29 @@ enum class EventColor(val rgba: Int) { companion object { /** - * Finds the best matching [EventColor] for a given RGBA value using a weighted Euclidian - * distance formula for RGB (A is being ignored). + * Returns the CSS3 color property of the given name. + * + * @param name color name + * @return [Css3Color] object or null if no match was found + */ + fun fromString(name: String) = + try { + valueOf(name) + } catch (e: IllegalArgumentException) { + Constants.log.warning("Unknown color: $name") + null + } + + /** + * Finds the best matching [Css3Color] for a given RGBA value using a weighted Euclidian + * distance formula for RGB. + * + * @param argb (A)RGB color (A will be ignored) */ - fun nearestMatch(rgba: Int): EventColor { - val rgb = rgba and 0xFFFFFF + fun nearestMatch(argb: Int): Css3Color { + val rgb = argb and 0xFFFFFF val distance = values().map { - val cssColor = it.rgba and 0xFFFFFF + val cssColor = it.argb and 0xFFFFFF val r1 = rgb shr 16 val r2 = cssColor shr 16 val r = (r1 + r2)/2.0 @@ -181,7 +201,7 @@ enum class EventColor(val rgba: Int) { val deltaB = (rgb and 0xFF) - (cssColor and 0xFF) val deltaR2 = deltaR*deltaR val deltaG2 = deltaG*deltaG - Math.sqrt(2.0*deltaR2 + 4.0*deltaG2 + 3.0*deltaB*deltaB + (r*(deltaR2 - deltaG2))/256.0) + sqrt(2.0*deltaR2 + 4.0*deltaG2 + 3.0*deltaB*deltaB + (r*(deltaR2 - deltaG2))/256.0) } val idx = distance.withIndex().minBy { it.value }!!.index return values()[idx] diff --git a/src/main/java/foundation/e/ical4android/DateUtils.kt b/src/main/java/foundation/e/ical4android/DateUtils.kt index 1f5199a49b3597fabfcd0eebb3bf0a3def98752f..3bf9b014c9c2e6841282b2e00a65db556cbc4ddf 100644 --- a/src/main/java/foundation/e/ical4android/DateUtils.kt +++ b/src/main/java/foundation/e/ical4android/DateUtils.kt @@ -15,6 +15,7 @@ import net.fortuna.ical4j.model.TimeZone import net.fortuna.ical4j.model.component.VTimeZone import net.fortuna.ical4j.model.parameter.Value import net.fortuna.ical4j.model.property.DateListProperty +import net.fortuna.ical4j.model.property.DateProperty import net.fortuna.ical4j.model.property.ExDate import net.fortuna.ical4j.model.property.RDate import java.io.StringReader @@ -74,6 +75,14 @@ object DateUtils { return deviceTZ } + /** + * Determines whether a given date represents a DATE-TIME value. + * @param date date property to check + * @return *true* if the date is a DATE-TIME value; *false* otherwise (for instance, when the + * date is a DATE value or null) + */ + fun isDateTime(date: DateProperty?) = date != null && date.date is DateTime + /** * Parses a VTIMEZONE definition to a VTimeZone object. * @param timezoneDef VTIMEZONE definition diff --git a/src/main/java/foundation/e/ical4android/Event.kt b/src/main/java/foundation/e/ical4android/Event.kt index 0e8c922ad9e116cd07fbf110fce730bf6736017d..57cbfa05794df200cece463c667f554e6ec7443f 100644 --- a/src/main/java/foundation/e/ical4android/Event.kt +++ b/src/main/java/foundation/e/ical4android/Event.kt @@ -8,11 +8,13 @@ package foundation.e.ical4android +import foundation.e.ical4android.DateUtils.isDateTime +import foundation.e.ical4android.ICalendar.Companion.CALENDAR_NAME import net.fortuna.ical4j.data.CalendarOutputter import net.fortuna.ical4j.data.ParserException +import net.fortuna.ical4j.model.* import net.fortuna.ical4j.model.Calendar -import net.fortuna.ical4j.model.Component -import net.fortuna.ical4j.model.Property +import net.fortuna.ical4j.model.Date import net.fortuna.ical4j.model.TimeZone import net.fortuna.ical4j.model.component.VAlarm import net.fortuna.ical4j.model.component.VEvent @@ -30,7 +32,7 @@ class Event: ICalendar() { var summary: String? = null var location: String? = null var description: String? = null - var color: EventColor? = null + var color: Css3Color? = null var dtStart: DtStart? = null var dtEnd: DtEnd? = null @@ -55,37 +57,26 @@ class Event: ICalendar() { var lastModified: LastModified? = null + val categories = LinkedList() val unknownProperties = LinkedList() companion object { - const val CALENDAR_NAME = "X-WR-CALNAME" - /** - * Parses an InputStream that contains iCalendar VEVENTs. + * Parses an iCalendar resource, applies [ICalPreprocessor] to increase compatibility + * and extracts the VEVENTs. + * + * @param reader where the iCalendar is taken from + * @param properties Known iCalendar properties (like [CALENDAR_NAME]) will be put into this map. Key: property name; value: property value + * + * @return array of filled [Event] data objects (may have size 0) * - * @param reader reader for the input stream containing the VEVENTs (pay attention to the charset) - * @param properties map of properties, will be filled with CALENDAR_* values, if applicable (may be null) - * @return array of filled Event data objects (may have size 0) – doesn't return null + * @throws ParserException when the iCalendar can't be parsed + * @throws IllegalArgumentException when the iCalendar resource contains an invalid value * @throws IOException on I/O errors * @throws InvalidCalendarException on parsing exceptions */ - fun fromReader(reader: Reader, properties: MutableMap? = null): List { - Constants.log.fine("Parsing iCalendar stream") - - // parse stream - val ical: Calendar - try { - ical = calendarBuilder().build(reader) - } catch(e: ParserException) { - throw InvalidCalendarException("Couldn't parse iCalendar resource", e) - } - - // fill calendar properties - properties?.let { - ical.getProperty(CALENDAR_NAME)?.let { calName -> - properties[CALENDAR_NAME] = calName.value - } - } + fun eventsFromReader(reader: Reader, properties: MutableMap? = null): List { + val ical = fromReader(reader, properties) // process VEVENTs val vEvents = ical.getComponents(Component.VEVENT) @@ -98,8 +89,8 @@ class Event: ICalendar() { vEvent.properties += uid } - Constants.log.fine("Assigning exceptions to master events") - val masterEvents = mutableMapOf() + Constants.log.fine("Assigning exceptions to main events") + val mainEvents = mutableMapOf() val exceptions = mutableMapOf>() for (vEvent in vEvents) { @@ -107,13 +98,13 @@ class Event: ICalendar() { val sequence = vEvent.sequence?.sequenceNo ?: 0 if (vEvent.recurrenceId == null) { - // master event (no RECURRENCE-ID) + // main event (no RECURRENCE-ID) // If there are multiple entries, compare SEQUENCE and use the one with higher SEQUENCE. // If the SEQUENCE is identical, use latest version. - val event = masterEvents[uid] + val event = mainEvents[uid] if (event == null || (event.sequence != null && sequence >= event.sequence.sequenceNo)) - masterEvents[uid] = vEvent + mainEvents[uid] = vEvent } else { // exception (RECURRENCE-ID) @@ -132,7 +123,7 @@ class Event: ICalendar() { } val events = mutableListOf() - for ((uid, vEvent) in masterEvents) { + for ((uid, vEvent) in mainEvents) { val event = fromVEvent(vEvent) exceptions[uid]?.let { eventExceptions -> event.exceptions.addAll(eventExceptions.map { (_,it) -> fromVEvent(it) }) @@ -162,7 +153,10 @@ class Event: ICalendar() { is Summary -> e.summary = prop.value is Location -> e.location = prop.value is Description -> e.description = prop.value - is Color -> e.color = prop.value + is Categories -> + for (category in prop.categories) + e.categories += category + is Color -> e.color = Css3Color.fromString(prop.value) is DtStart -> e.dtStart = prop is DtEnd -> e.dtEnd = prop is Duration -> e.duration = prop @@ -176,7 +170,7 @@ class Event: ICalendar() { is Organizer -> e.organizer = prop is Attendee -> e.attendees += prop is LastModified -> e.lastModified = prop - is ProdId, is DtStamp -> { /* don't save those as unknown properties */ } + is ProdId, is DtStamp -> { /* don't save these as unknown properties */ } else -> e.unknownProperties += prop } @@ -200,20 +194,46 @@ class Event: ICalendar() { ical.properties += Version.VERSION_2_0 ical.properties += prodId - // "master event" (without exceptions) + val dtStart = dtStart ?: throw InvalidCalendarException("Won't generate event without start time") + + // "main event" (without exceptions) val components = ical.components - val master = toVEvent(Uid(uid)) - components += master + val mainEvent = toVEvent() + components += mainEvent // remember used time zones val usedTimeZones = mutableSetOf() - dtStart?.timeZone?.let(usedTimeZones::add) + dtStart.timeZone?.let(usedTimeZones::add) dtEnd?.timeZone?.let(usedTimeZones::add) // recurrence exceptions for (exception in exceptions) { - // create VEVENT for exception - val vException = exception.toVEvent(master.uid) + // exceptions must always have the same UID as the main event + exception.uid = uid + + val recurrenceId = exception.recurrenceId + if (recurrenceId == null) { + Constants.log.warning("Ignoring exception without recurrenceId") + continue + } + + /* Exceptions must always have the same value type as DTSTART [RFC 5545 3.8.4.4]. + If this is not the case, we don't add the exception to the event because we're + strict in what we send (and servers may reject such a case). + */ + if (isDateTime(recurrenceId) != isDateTime(dtStart)) { + Constants.log.warning("Ignoring exception $recurrenceId with other date type than dtStart: $dtStart") + continue + } + + // for simplicity and compatibility, rewrite date-time exceptions to the same time zone as DTSTART + if (isDateTime(recurrenceId) && recurrenceId.timeZone != dtStart.timeZone) { + Constants.log.fine("Changing timezone of $recurrenceId to same time zone as dtStart: $dtStart") + recurrenceId.timeZone = dtStart.timeZone + } + + // create and add VEVENT for exception + val vException = exception.toVEvent() components += vException // remember used time zones @@ -228,23 +248,32 @@ class Event: ICalendar() { ical.components += tz } + softValidate(ical) CalendarOutputter(false).output(ical, os) } - private fun toVEvent(uid: Uid): VEvent { - val event = VEvent() + /** + * Generates a VEvent representation of this event. + * + * @return generated VEvent + */ + private fun toVEvent(): VEvent { + val event = VEvent(/* generates DTSTAMP */) val props = event.properties + props += Uid(uid) - props += uid recurrenceId?.let { props += it } - sequence?.let { if (it != 0) props += Sequence(it) } + sequence?.let { + if (it != 0) + props += Sequence(it) + } summary?.let { props += Summary(it) } location?.let { props += Location(it) } description?.let { props += Description(it) } - color?.let { props += Color(it) } + color?.let { props += Color(null, it.name) } - props += dtStart + dtStart?.let { props += it } dtEnd?.let { props += it } duration?.let { props += it } @@ -261,17 +290,25 @@ class Event: ICalendar() { organizer?.let { props += it } props.addAll(attendees) + if (categories.isNotEmpty()) + props += Categories(TextList(categories.toTypedArray())) props.addAll(unknownProperties) lastModified?.let { props += it } event.alarms.addAll(alarms) + return event } // helpers - fun isAllDay() = !isDateTime(dtStart) + /** + * Determines whether this Event is an all-day event. + * + * @return *true* if [dtStart] is a DATE value; *false* otherwise ([dtStart] is a DATETIME value or *null*) + */ + fun isAllDay() = dtStart != null && !isDateTime(dtStart) } diff --git a/src/main/java/foundation/e/ical4android/ICalPreprocessor.kt b/src/main/java/foundation/e/ical4android/ICalPreprocessor.kt new file mode 100644 index 0000000000000000000000000000000000000000..7b54bab7b71ff834cd6dc3cf27fe90fc08fa76e3 --- /dev/null +++ b/src/main/java/foundation/e/ical4android/ICalPreprocessor.kt @@ -0,0 +1,49 @@ +package foundation.e.ical4android + +import net.fortuna.ical4j.model.Calendar +import net.fortuna.ical4j.model.Property +import net.fortuna.ical4j.transform.rfc5545.CreatedPropertyRule +import net.fortuna.ical4j.transform.rfc5545.DateListPropertyRule +import net.fortuna.ical4j.transform.rfc5545.DatePropertyRule +import net.fortuna.ical4j.transform.rfc5545.Rfc5545PropertyRule +import java.util.logging.Level + +/** + * Applies some rules to increase compatibility or parsed iCalendars: + * + * - [DatePropertyRule], [DateListPropertyRule]: to rename Outlook-specific TZID parameters + * (like "W. Europe Standard Time" to an Android-friendly name like "Europe/Vienna") + * + */ +object ICalPreprocessor { + + private val propertyRules = arrayOf( + CreatedPropertyRule(), // make sure CREATED is UTC + DatePropertyRule(), + DateListPropertyRule() + ) + + /** + * Applies the set of rules (see class definition) to a given calendar object. + * + * @param calendar the calendar object that is going to be modified + */ + fun preProcess(calendar: Calendar) { + for (component in calendar.components) { + for (property in component.properties) + applyRules(property) + } + } + + @Suppress("UNCHECKED_CAST") + private fun applyRules(property: Property) { + propertyRules + .filter { rule -> rule.supportedType.isAssignableFrom(property::class.java) } + .forEach { + Constants.log.log(Level.FINER, "Applying rules to ${property.toString()}") + (it as Rfc5545PropertyRule).applyTo(property) + Constants.log.log(Level.FINER, "-> ${property.toString()}") + } + } + +} diff --git a/src/main/java/foundation/e/ical4android/ICalendar.kt b/src/main/java/foundation/e/ical4android/ICalendar.kt index 211d71178d6a1d10bda0811c2121dfe38e7890c4..d9498752a41ed899e8f5bad46f444beed1fa104c 100644 --- a/src/main/java/foundation/e/ical4android/ICalendar.kt +++ b/src/main/java/foundation/e/ical4android/ICalendar.kt @@ -9,15 +9,16 @@ package foundation.e.ical4android import net.fortuna.ical4j.data.CalendarBuilder -import net.fortuna.ical4j.data.CalendarParserFactory import net.fortuna.ical4j.data.ParserException -import net.fortuna.ical4j.model.* +import net.fortuna.ical4j.model.Calendar import net.fortuna.ical4j.model.Date +import net.fortuna.ical4j.model.DateTime import net.fortuna.ical4j.model.component.* import net.fortuna.ical4j.model.property.DateProperty import net.fortuna.ical4j.model.property.ProdId import net.fortuna.ical4j.model.property.TzUrl -import net.fortuna.ical4j.util.Strings +import net.fortuna.ical4j.validate.ValidationException +import java.io.Reader import java.io.StringReader import java.util.* import java.util.logging.Level @@ -29,37 +30,74 @@ open class ICalendar { var sequence: Int? = null companion object { + // static ical4j initialization init { - // reduce verbosity of those two loggers + // reduce verbosity of various ical4j loggers org.slf4j.LoggerFactory.getLogger(net.fortuna.ical4j.data.CalendarParserImpl::class.java) Logger.getLogger(net.fortuna.ical4j.data.CalendarParserImpl::class.java.name).level = Level.CONFIG + org.slf4j.LoggerFactory.getLogger(net.fortuna.ical4j.model.Recur::class.java) Logger.getLogger(net.fortuna.ical4j.model.Recur::class.java.name).level = Level.CONFIG + + org.slf4j.LoggerFactory.getLogger(net.fortuna.ical4j.data.FoldingWriter::class.java) + Logger.getLogger(net.fortuna.ical4j.data.FoldingWriter::class.java.name).level = Level.CONFIG } - var prodId = ProdId("+//IDN e.foundation//ical4android") + // known iCalendar properties + const val CALENDAR_NAME = "X-WR-CALNAME" - private val parameterFactoryRegistry = ParameterFactoryRegistry() - init { - parameterFactoryRegistry.register(Email.PARAMETER_NAME, Email.Factory) - } + /** + * Default PRODID used when generating iCalendars. If you want another value, set it + * statically before writing the first iCalendar. + */ + var prodId = ProdId("+//IDN bitfire.at//ical4android") - private val propertyFactoryRegistry = PropertyFactoryRegistry() - init { - propertyFactoryRegistry.register(Color.PROPERTY_NAME, Color.Factory) - } - @JvmStatic - protected fun calendarBuilder() = CalendarBuilder( - CalendarParserFactory.getInstance().createParser(), - propertyFactoryRegistry, parameterFactoryRegistry, - TimeZoneRegistryFactory.getInstance().createRegistry()) + // parser + /** + * Parses an iCalendar resource and applies [ICalPreprocessor] to increase compatibility. + * + * @param reader where the iCalendar is taken from + * @param properties Known iCalendar properties (like [CALENDAR_NAME]) will be put into this map. Key: property name; value: property value + * + * @return parsed iCalendar resource + * @throws ParserException when the iCalendar can't be parsed + * @throws IllegalArgumentException when the iCalendar resource contains an invalid value + */ + fun fromReader(reader: Reader, properties: MutableMap? = null): Calendar { + Constants.log.fine("Parsing iCalendar stream") - // time zone helpers + // parse stream + val calendar: Calendar + try { + calendar = CalendarBuilder().build(reader) + } catch(e: ParserException) { + throw InvalidCalendarException("Couldn't parse iCalendar", e) + } catch(e: IllegalArgumentException) { + throw InvalidCalendarException("iCalendar contains invalid value", e) + } + + // apply ICalPreprocessor for increased compatibility + try { + ICalPreprocessor.preProcess(calendar) + } catch (e: Exception) { + Constants.log.log(Level.WARNING, "Couldn't pre-process iCalendar", e) + } + + // fill calendar properties + properties?.let { + calendar.getProperty(CALENDAR_NAME)?.let { calName -> + properties[CALENDAR_NAME] = calName.value + } + } + + return calendar + } - fun isDateTime(date: DateProperty?) = date != null && date.date is DateTime + + // time zone helpers /** * Minifies a VTIMEZONE so that only components after [start] are kept. @@ -110,7 +148,7 @@ open class ICalendar { } // remove TZURL - tz.properties.filter { it is TzUrl }.forEach { + tz.properties.filterIsInstance().forEach { tz.properties.remove(it) } } @@ -132,6 +170,28 @@ open class ICalendar { return null } + /** + * Validates an iCalendar resource. + * + * Debug builds only: throws [ValidationException] when the resource is invalid. + * Release builds only: prints a warning to the log when the resource is invalid. + * + * @param ical iCalendar resource to be validated + * + * @throws ValidationException when the resource is invalid (only if [BuildConfig.DEBUG] is set) + */ + fun softValidate(ical: Calendar) { + try { + ical.validate(true) + } catch (e: ValidationException) { + if (BuildConfig.DEBUG) + // debug build, re-throw ValidationException + throw e + else + Constants.log.log(Level.WARNING, "iCalendar validation failed - This is only a warning!", e) + } + } + // misc. iCalendar helpers @@ -155,66 +215,4 @@ open class ICalendar { override fun toString() = MiscUtils.reflectionToString(this) - - // ical4j helpers and extensions - - /** COLOR property for VEVENT components [RFC 7986 5.9 COLOR] */ - class Color( - var value: EventColor? = null - ): Property(PROPERTY_NAME, Factory) { - companion object { - const val PROPERTY_NAME = "COLOR" - } - - override fun getValue() = value?.name - - override fun setValue(name: String?) { - name?.let { - try { - value = EventColor.valueOf(name.toLowerCase()) - } catch(e: IllegalArgumentException) { - Constants.log.warning("Ignoring unknown COLOR $name") - } - } - } - - override fun validate() { - } - - object Factory: PropertyFactory { - override fun createProperty() = Color() - - override fun createProperty(params: ParameterList?, value: String?): Color { - val c = Color() - c.setValue(value) - return c - } - - override fun supports(property: String?) = property == PROPERTY_NAME - } - - } - - /** EMAIL parameter for ATTENDEE properties, as used by iCloud: - ATTENDEE;EMAIL=bla@domain.tld;/path/to/principal - */ - class Email(): Parameter(PARAMETER_NAME, Factory) { - companion object { - const val PARAMETER_NAME = "EMAIL" - } - - var email: String? = null - override fun getValue() = email - - constructor(aValue: String): this() - { - email = Strings.unquote(aValue) - } - - object Factory: ParameterFactory { - override fun createParameter(value: String) = Email(value) - override fun supports(name: String) = name == PARAMETER_NAME - } - } - } \ No newline at end of file diff --git a/src/main/java/foundation/e/ical4android/MiscUtils.kt b/src/main/java/foundation/e/ical4android/MiscUtils.kt index 96461929fece900f1fc635c5a53426d1cd56e3d5..9e6af7c603c48a7664e54974236297b1813ff203 100644 --- a/src/main/java/foundation/e/ical4android/MiscUtils.kt +++ b/src/main/java/foundation/e/ical4android/MiscUtils.kt @@ -9,6 +9,9 @@ package foundation.e.ical4android import android.content.ContentValues +import android.database.Cursor +import android.database.DatabaseUtils +import foundation.e.ical4android.DateUtils.isDateTime import net.fortuna.ical4j.model.property.DateProperty import net.fortuna.ical4j.util.TimeZones import java.lang.reflect.Modifier @@ -18,33 +21,53 @@ object MiscUtils { /** * Ensures that a given DateProperty has a time zone with an ID that is available in Android. + * * @param date DateProperty to validate. Values which are not DATE-TIME will be ignored. */ fun androidifyTimeZone(date: DateProperty?) { - if (ICalendar.isDateTime(date)) { + if (isDateTime(date)) { val tz = date!!.timeZone ?: return val tzID = tz.id ?: return val deviceTzID = DateUtils.findAndroidTimezoneID(tzID) if (tzID != deviceTzID) { - Constants.log.warning("Android doesn't know time zone \"$tzID\", storing event in time zone \"$deviceTzID\"") + Constants.log.warning("Android doesn't know time zone \"$tzID\", assuming device time zone \"$deviceTzID\"") date.timeZone = DateUtils.tzRegistry.getTimeZone(deviceTzID) } } } /** - * Returns the time-zone ID for a given date-time, or TIMEZONE_UTC for dates (without time). - * TIMEZONE_UTC is also returned for DATE-TIMEs in UTC representation. + * Returns the time-zone ID for a given date or date-time that should be used to store it + * in the Android calendar storage. + * * @param date DateProperty (DATE or DATE-TIME) whose time-zone information is used + * + * @return - UTC for dates and UTC date-times + * - the specified time zone ID for date-times with given time zone + * - the currently set default time zone ID for floating date-times */ - fun getTzId(date: DateProperty?) = - if (ICalendar.isDateTime(date!!) && !date.isUtc && date.timeZone != null) - date.timeZone.id!! - else + fun getTzId(date: DateProperty): String = + if (isDateTime(date)) { + when { + date.isUtc -> + // DATE-TIME in UTC format + TimeZones.UTC_ID + date.timeZone != null -> + // DATE-TIME with given time-zone + date.timeZone.id + else /* date.timeZone == null */ -> + // DATE-TIME in local format (floating) + TimeZone.getDefault().id + } + } else + // DATE TimeZones.UTC_ID /** * Generates useful toString info (fields and values) from [obj] by reflection. + * + * @param obj object to inspect + * @return string containing properties and non-static declared fields */ fun reflectionToString(obj: Any): String { val s = LinkedList() @@ -61,15 +84,39 @@ object MiscUtils { /** * Removes empty [String] values from [values]. - * @param values set of values to be processed + * + * @param values set of values to be modified + * @return the modified object (which is the same object as passed in; for chaining) */ - fun removeEmptyStrings(values: ContentValues) { + fun removeEmptyStrings(values: ContentValues): ContentValues { val it = values.keySet().iterator() while (it.hasNext()) { val obj = values[it.next()] if (obj is String && obj.isEmpty()) it.remove() } + return values + } + + + object CursorHelper { + + /** + * Returns the entire contents of the current row as a [ContentValues] object. + * + * @param removeEmptyRows whether rows with empty values should be removed + * @return entire contents of the current row + */ + fun Cursor.toValues(removeEmptyRows: Boolean = false): ContentValues { + val values = ContentValues(columnCount) + DatabaseUtils.cursorRowToContentValues(this, values) + + if (removeEmptyRows) + removeEmptyStrings(values) + + return values + } + } } \ No newline at end of file diff --git a/src/main/java/foundation/e/ical4android/Task.kt b/src/main/java/foundation/e/ical4android/Task.kt index cc5634c753fc3335587d03f5956a81ec1720e95e..9c18543c4f34b6ca9bdd2c3495c72356697e48ef 100644 --- a/src/main/java/foundation/e/ical4android/Task.kt +++ b/src/main/java/foundation/e/ical4android/Task.kt @@ -1,19 +1,21 @@ /* - * Copyright © Ricki Hirner (bitfire web engineering). + * Copyright © Ricki Hirner and contributors (bitfire web engineering). * All rights reserved. This program and the accompanying materials * are made available under the terms of the GNU Public License v3.0 * which accompanies this distribution, and is available at * http://www.gnu.org/licenses/gpl.html + * + * Contributors: Alex Baker */ package foundation.e.ical4android import net.fortuna.ical4j.data.CalendarOutputter import net.fortuna.ical4j.data.ParserException +import net.fortuna.ical4j.model.* import net.fortuna.ical4j.model.Calendar -import net.fortuna.ical4j.model.Component -import net.fortuna.ical4j.model.DateTime import net.fortuna.ical4j.model.TimeZone +import net.fortuna.ical4j.model.component.VAlarm import net.fortuna.ical4j.model.component.VToDo import net.fortuna.ical4j.model.property.* import java.io.IOException @@ -26,7 +28,7 @@ import java.util.logging.Level class Task: ICalendar() { - var createdAt: Long? = null + var createdAt: Long? = null var lastModified: Long? = null var summary: String? = null @@ -50,24 +52,28 @@ class Task: ICalendar() { val rDates = LinkedList() val exDates = LinkedList() + val categories = LinkedList() + var relatedTo = LinkedList() + val unknownProperties = LinkedList() + + val alarms = LinkedList() + companion object { /** - * Parses an InputStream that contains iCalendar VTODOs. + * Parses an iCalendar resource, applies [ICalPreprocessor] to increase compatibility + * and extracts the VTODOs. + * + * @param reader where the iCalendar is taken from + * + * @return array of filled [Task] data objects (may have size 0) * - * @param reader reader for the input stream containing the VTODOs (pay attention to the charset) - * @return array of filled Task data objects (may have size 0) – doesn't return null - * @throws IOException - * @throws InvalidCalendarException on parser exceptions + * @throws ParserException when the iCalendar can't be parsed + * @throws IllegalArgumentException when the iCalendar resource contains an invalid value + * @throws IOException on I/O errors */ - fun fromReader(reader: Reader): List { - val ical: Calendar - try { - ical = calendarBuilder().build(reader) - } catch (e: ParserException) { - throw InvalidCalendarException("Couldn't parse iCalendar resource", e) - } - + fun tasksFromReader(reader: Reader): List { + val ical = fromReader(reader) val vToDos = ical.getComponents(Component.VTODO) return vToDos.mapTo(LinkedList()) { this.fromVToDo(it) } } @@ -94,7 +100,7 @@ class Task: ICalendar() { is Location -> t.location = prop.value is Geo -> t.geoPosition = prop is Description -> t.description = prop.value - is Color -> t.color = prop.value?.rgba + is Color -> t.color = Css3Color.fromString(prop.value)?.argb is Url -> t.url = prop.value is Organizer -> t.organizer = prop is Priority -> t.priority = prop.level @@ -108,8 +114,16 @@ class Task: ICalendar() { is RRule -> t.rRule = prop is RDate -> t.rDates += prop is ExDate -> t.exDates += prop + is Categories -> + for (category in prop.categories) + t.categories += category + is RelatedTo -> t.relatedTo.add(prop) + is Uid, is ProdId, is DtStamp -> { /* don't save these as unknown properties */ } + else -> t.unknownProperties += prop } + t.alarms.addAll(todo.alarms) + // there seem to be many invalid tasks out there because of some defect clients, // do some validation val dtStart = t.dtStart @@ -125,27 +139,30 @@ class Task: ICalendar() { } - fun write(os: OutputStream) { - val ical = Calendar() - ical.properties += Version.VERSION_2_0 - ical.properties += prodId + fun write(os: OutputStream) { + val ical = Calendar() + ical.properties += Version.VERSION_2_0 + ical.properties += prodId - val todo = VToDo() - ical.components += todo - val props = todo.properties + val vTodo = VToDo(true /* generates DTSTAMP */) + ical.components += vTodo + val props = vTodo.properties - uid?.let { props += Uid(it) } - sequence?.let { if (it != 0) props += Sequence(sequence as Int) } + uid?.let { props += Uid(uid) } + sequence?.let { + if (it != 0) + props += Sequence(it) + } - createdAt?.let { props += Created(DateTime(it)) } + createdAt?.let { props += Created(DateTime(it)) } lastModified?.let { props += LastModified(DateTime(it)) } summary?.let { props += Summary(it) } location?.let { props += Location(it) } geoPosition?.let { props += it } description?.let { props += Description(it) } - color?.let { props += Color(EventColor.nearestMatch(it)) } - url?.let { + color?.let { props += Color(null, Css3Color.nearestMatch(it).name) } + url?.let { try { props += Url(URI(it)) } catch (e: URISyntaxException) { @@ -155,7 +172,7 @@ class Task: ICalendar() { organizer?.let { props += it } if (priority != Priority.UNDEFINED.level) - props += Priority(priority) + props += Priority(priority) classification?.let { props += it } status?.let { props += it } @@ -163,12 +180,17 @@ class Task: ICalendar() { rDates.forEach { props += it } exDates.forEach { props += it } - // remember used time zones - val usedTimeZones = HashSet() - due?.let { + if (categories.isNotEmpty()) + props += Categories(TextList(categories.toTypedArray())) + props.addAll(relatedTo) + props.addAll(unknownProperties) + + // remember used time zones + val usedTimeZones = HashSet() + due?.let { props += it it.timeZone?.let(usedTimeZones::add) - } + } duration?.let(props::add) dtStart?.let { props += it @@ -178,13 +200,16 @@ class Task: ICalendar() { props += it it.timeZone?.let(usedTimeZones::add) } - percentComplete?.let { props += PercentComplete(it) } + percentComplete?.let { props += PercentComplete(it) } + + if (alarms.isNotEmpty()) + vTodo.alarms.addAll(alarms) - // add VTIMEZONE components - usedTimeZones.forEach { ical.components += it.vTimeZone } + ical.components.addAll(usedTimeZones.map { it.vTimeZone }) + softValidate(ical) CalendarOutputter(false).output(ical, os) - } + } fun isAllDay(): Boolean { diff --git a/src/main/java/foundation/e/ical4android/TaskProvider.kt b/src/main/java/foundation/e/ical4android/TaskProvider.kt index bf8c098a5b901eb9cc7fcf4263e3ee6ad3067c16..59e67f16fb07a6988cd97325b74ce26186588f52 100644 --- a/src/main/java/foundation/e/ical4android/TaskProvider.kt +++ b/src/main/java/foundation/e/ical4android/TaskProvider.kt @@ -15,6 +15,7 @@ import android.content.Context import android.content.pm.PackageManager import android.net.Uri import android.os.Build +import androidx.core.content.pm.PackageInfoCompat import org.dmfs.tasks.contract.TaskContract import java.io.Closeable import java.util.logging.Level @@ -27,11 +28,11 @@ class TaskProvider private constructor( enum class ProviderName( val authority: String, val packageName: String, - val minVersionCode: Int, + val minVersionCode: Long, val minVersionName: String ) { //Mirakel("de.azapps.mirakel.provider"), - OpenTasks("org.dmfs.tasks", "org.dmfs.tasks", 103, "1.1.8.2") + OpenTasks("foundation.e.tasks", "foundation.e.tasks", 103, "1.1.8.2") } companion object { @@ -48,7 +49,7 @@ class TaskProvider private constructor( * @throws [ProviderTooOldException] if the tasks provider is installed, but doesn't meet the minimum version requirement */ @SuppressLint("Recycle") - fun acquire(context: Context, name: TaskProvider.ProviderName): TaskProvider? { + fun acquire(context: Context, name: ProviderName): TaskProvider? { return try { checkVersion(context, name) @@ -80,8 +81,9 @@ class TaskProvider private constructor( private fun checkVersion(context: Context, name: ProviderName) { // check whether package is available with required minimum version val info = context.packageManager.getPackageInfo(name.packageName, 0) - if (info.versionCode < name.minVersionCode) { - val exception = ProviderTooOldException(name, info.versionCode, info.versionName) + val installedVersionCode = PackageInfoCompat.getLongVersionCode(info) + if (installedVersionCode < name.minVersionCode) { + val exception = ProviderTooOldException(name, installedVersionCode, info.versionName) Constants.log.log(Level.WARNING, "Task provider too old", exception) throw exception } @@ -97,10 +99,13 @@ class TaskProvider private constructor( fun taskListsUri() = TaskContract.TaskLists.getContentUri(name.authority)!! - fun tasksUri() = TaskContract.Tasks.getContentUri(name.authority)!! - //fun alarmsUri() = TaskContract.Alarms.getContentUri(name.authority)!! fun syncStateUri() = TaskContract.SyncState.getContentUri(name.authority)!! + fun tasksUri() = TaskContract.Tasks.getContentUri(name.authority)!! + fun propertiesUri() = TaskContract.Properties.getContentUri(name.authority)!! + fun alarmsUri() = TaskContract.Alarms.getContentUri(name.authority)!! + fun categoriesUri() = TaskContract.Categories.getContentUri(name.authority)!! + override fun close() { if (Build.VERSION.SDK_INT >= 24) @@ -113,7 +118,7 @@ class TaskProvider private constructor( class ProviderTooOldException( val provider: ProviderName, - val installedVersionCode: Int, + installedVersionCode: Long, val installedVersionName: String ): Exception("Package ${provider.packageName} has version $installedVersionName ($installedVersionCode), " + "required: ${provider.minVersionName} (${provider.minVersionCode})") diff --git a/src/main/java/foundation/e/ical4android/UnknownProperty.kt b/src/main/java/foundation/e/ical4android/UnknownProperty.kt new file mode 100644 index 0000000000000000000000000000000000000000..e0f014f9f357e5d8aaeae8686c5624eeb6222329 --- /dev/null +++ b/src/main/java/foundation/e/ical4android/UnknownProperty.kt @@ -0,0 +1,80 @@ +package foundation.e.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 diff --git a/src/main/res/values/about_strings.xml b/src/main/res/values/about_strings.xml new file mode 100644 index 0000000000000000000000000000000000000000..2d005286a491e62dc599fd427350fd56ad9678b7 --- /dev/null +++ b/src/main/res/values/about_strings.xml @@ -0,0 +1,13 @@ + + + + + Ricki Hirner + https://www.bitfire.at + ical4android + Library that brings together iCalendar and Android + https://gitlab.com/bitfireAT/ical4android + gpl_3_0 + true + + \ No newline at end of file diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml deleted file mode 100644 index 59c48ce3242432bb6548059fe74696b509ad9c76..0000000000000000000000000000000000000000 --- a/src/main/res/values/strings.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - iCal4Android - diff --git a/src/main/resources/ical4j.properties b/src/main/resources/ical4j.properties index cf9d49bf4fba8aeaa186df64854c24768361d7e3..dfa5830817bd4fe03354688ed9d451db6133e8cd 100644 --- a/src/main/resources/ical4j.properties +++ b/src/main/resources/ical4j.properties @@ -2,5 +2,4 @@ net.fortuna.ical4j.timezone.cache.impl=net.fortuna.ical4j.util.MapTimeZoneCache net.fortuna.ical4j.timezone.update.enabled=false ical4j.parsing.relaxed=true ical4j.unfolding.relaxed=true -ical4j.validation.relaxed=true ical4j.compatibility.outlook=true diff --git a/src/test/java/foundation/e/ical4android/Css3ColorTest.kt b/src/test/java/foundation/e/ical4android/Css3ColorTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..13c44614bdcdb5e4dcbff1e0c44b9f2f077a822b --- /dev/null +++ b/src/test/java/foundation/e/ical4android/Css3ColorTest.kt @@ -0,0 +1,24 @@ +/* + * Copyright © Ricki Hirner (bitfire web engineering). + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the GNU Public License v3.0 + * which accompanies this distribution, and is available at + * http://www.gnu.org/licenses/gpl.html + */ + +package foundation.e.ical4android + +import org.junit.Assert.assertEquals +import org.junit.Test + +class Css3ColorTest { + + @Test + fun testNearestMatch() { + // every color is its own nearest match + Css3Color.values().forEach { + assertEquals(it.argb, Css3Color.nearestMatch(it.argb).argb) + } + } + +} \ No newline at end of file diff --git a/src/test/java/foundation/e/ical4android/EventColorTest.kt b/src/test/java/foundation/e/ical4android/EventColorTest.kt index 95b60f574d945dec7dc4a9b788aee0a92c83c171..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 100644 --- a/src/test/java/foundation/e/ical4android/EventColorTest.kt +++ b/src/test/java/foundation/e/ical4android/EventColorTest.kt @@ -1,24 +0,0 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ - -package foundation.e.ical4android - -import org.junit.Assert.assertEquals -import org.junit.Test - -class EventColorTest { - - @Test - fun testNearestMatch() { - // every color is its own nearest match - EventColor.values().forEach { - assertEquals(it.rgba, EventColor.nearestMatch(it.rgba).rgba) - } - } - -} \ No newline at end of file diff --git a/src/test/java/foundation/e/ical4android/EventTest.kt b/src/test/java/foundation/e/ical4android/EventTest.kt index cecb56fe475b3634acf3f2f444a23388a86c89dd..acd604830c2307718c43439d751a92031cc25552 100644 --- a/src/test/java/foundation/e/ical4android/EventTest.kt +++ b/src/test/java/foundation/e/ical4android/EventTest.kt @@ -7,11 +7,19 @@ */ package foundation.e.ical4android +import net.fortuna.ical4j.model.Date +import net.fortuna.ical4j.model.DateTime +import net.fortuna.ical4j.model.Dur +import net.fortuna.ical4j.model.TimeZoneRegistryFactory +import net.fortuna.ical4j.model.component.VAlarm +import net.fortuna.ical4j.model.property.DtStart +import net.fortuna.ical4j.model.property.RRule +import net.fortuna.ical4j.model.property.RecurrenceId import org.junit.Assert.* import org.junit.Test +import java.io.ByteArrayOutputStream import java.io.FileNotFoundException import java.io.InputStreamReader -import java.lang.AssertionError import java.nio.charset.Charset class EventTest { @@ -20,17 +28,17 @@ class EventTest { @Test fun testCalendarProperties() { - javaClass.classLoader.getResourceAsStream("events/multiple.ics").use { stream -> + javaClass.classLoader!!.getResourceAsStream("events/multiple.ics").use { stream -> val properties = mutableMapOf() - Event.fromReader(InputStreamReader(stream, Charsets.UTF_8), properties) + Event.eventsFromReader(InputStreamReader(stream, Charsets.UTF_8), properties) assertEquals(1, properties.size) - assertEquals("Test-Kalender", properties[Event.CALENDAR_NAME]) + assertEquals("Test-Kalender", properties[ICalendar.CALENDAR_NAME]) } } @Test fun testCharsets() { - var e = parseCalendar("latin1.ics", Charset.forName("ISO-8859-1"))[0] + var e = parseCalendar("latin1.ics", Charsets.ISO_8859_1)[0] assertEquals("äöüß", e.summary) e = parseCalendar("utf8.ics").first() @@ -38,19 +46,15 @@ class EventTest { assertEquals("中华人民共和国", e.location) } - @Test(expected = AssertionError::class) + @Test fun testDstOnlyVtimezone() { - // Google Calendar (and maybe others) generate iCalendars which contain VTIMEZONE definitions - // with only a DAYLIGHT component (and no STANDARD component). This is technically valid, - // but results in wrong dates in ical4j: - // https://github.com/ical4j/ical4j/issues/230 + // see https://github.com/ical4j/ical4j/issues/230 val events = parseCalendar("dst-only-vtimezone.ics") assertEquals(1, events.size) val e = events.first() assertEquals("only-dst@example.com", e.uid) val dtStart = e.dtStart!! assertEquals("Europe/Berlin", dtStart.timeZone.id) - // FIXME this should not fail, but it does: assertEquals(1522738800000L, dtStart.date.time) } @@ -76,13 +80,64 @@ class EventTest { } @Test - fun testParseAndWrite() { + fun testIsAllDay() { + assertFalse(Event().isAllDay()) + assertTrue(Event().apply { dtStart = DtStart(Date("20190101")) }.isAllDay()) + assertFalse(Event().apply { dtStart = DtStart(DateTime("20190101T010100")) }.isAllDay()) + } + + @Test + fun testParse() { val event = parseCalendar("utf8.ics").first() assertEquals("utf8@ical4android.EventTest", event.uid) assertEquals("© äö — üß", event.summary) assertEquals("Test Description", event.description) assertEquals("中华人民共和国", event.location) - assertEquals(EventColor.aliceblue, event.color) + 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 + fun testRecurringWriteFullDayException() { + val event = Event().apply { + uid = "test1" + dtStart = DtStart("20190117T083000", DateUtils.tzRegistry.getTimeZone("Europe/Berlin")) + summary = "Main event" + rRule = RRule("FREQ=DAILY;COUNT=5") + exceptions += arrayOf( + Event().apply { + uid = "test2" + recurrenceId = RecurrenceId(DateTime("20190118T073000", DateUtils.tzRegistry.getTimeZone("Europe/London"))) + summary = "Normal exception" + }, + Event().apply { + uid = "test3" + recurrenceId = RecurrenceId(Date("20190223")) + summary = "Full-day exception" + } + ) + } + val baos = ByteArrayOutputStream() + event.write(baos) + val iCal = baos.toString() + assertTrue(iCal.contains("UID:test1\r\n")) + assertTrue(iCal.contains("DTSTART;TZID=Europe/Berlin:20190117T083000\r\n")) + + // first RECURRENCE-ID has been rewritten + // - to main event's UID + // - to time zone Europe/Berlin (with one hour time difference) + assertTrue(iCal.contains("UID:test1\r\n" + + "RECURRENCE-ID;TZID=Europe/Berlin:20190118T083000\r\n" + + "SUMMARY:Normal exception\r\n" + + "END:VEVENT")) + + // no RECURRENCE-ID;VALUE=DATE:20190223 + assertFalse(iCal.contains(":20190223")) } @Test @@ -101,9 +156,9 @@ class EventTest { // event with start+end date-time val eViennaEvolution = parseCalendar("vienna-evolution.ics").first() assertEquals(1381330800000L, eViennaEvolution.dtStart!!.date.time) - assertEquals("/freeassociation.sourceforge.net/Tzfile/Europe/Vienna", eViennaEvolution.dtStart!!.timeZone.id) + assertEquals("Europe/Vienna", eViennaEvolution.dtStart!!.timeZone.id) assertEquals(1381334400000L, eViennaEvolution.dtEnd!!.date.time) - assertEquals("/freeassociation.sourceforge.net/Tzfile/Europe/Vienna", eViennaEvolution.dtEnd!!.timeZone.id) + assertEquals("Europe/Vienna", eViennaEvolution.dtEnd!!.timeZone.id) } @Test @@ -152,6 +207,30 @@ class EventTest { } + /* generating */ + + @Test + fun testWrite() { + val e = Event() + e.uid = "SAMPLEUID" + e.dtStart = DtStart("20190101T100000", TimeZoneRegistryFactory.getInstance().createRegistry().getTimeZone("Europe/Berlin")) + e.alarms += VAlarm(Dur(0, -1, 0, 0)) + + val os = ByteArrayOutputStream() + e.write(os) + val raw = os.toString(Charsets.UTF_8.name()) + + assertTrue(raw.contains("PRODID:${ICalendar.prodId.value}")) + assertTrue(raw.contains("UID:SAMPLEUID")) + assertTrue(raw.contains("DTSTART;TZID=Europe/Berlin:20190101T100000")) + assertTrue(raw.contains("DTSTAMP:")) + assertTrue(raw.contains("BEGIN:VALARM\r\n" + + "TRIGGER:-PT1H\r\n" + + "END:VALARM\r\n")) + assertTrue(raw.contains("BEGIN:VTIMEZONE")) + } + + /* internal tests */ @Test @@ -201,8 +280,8 @@ class EventTest { } private fun parseCalendar(fname: String, charset: Charset = Charsets.UTF_8): List = - javaClass.classLoader.getResourceAsStream("events/$fname").use { stream -> - return Event.fromReader(InputStreamReader(stream, charset)) + javaClass.classLoader!!.getResourceAsStream("events/$fname").use { stream -> + return Event.eventsFromReader(InputStreamReader(stream, charset)) } } diff --git a/src/test/java/foundation/e/ical4android/ICalPreprocessorTest.kt b/src/test/java/foundation/e/ical4android/ICalPreprocessorTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..ea999190ccd3de338e5d0df60fb8cb76b570cdec --- /dev/null +++ b/src/test/java/foundation/e/ical4android/ICalPreprocessorTest.kt @@ -0,0 +1,25 @@ +package foundation.e.ical4android + +import net.fortuna.ical4j.data.CalendarBuilder +import net.fortuna.ical4j.model.Component +import net.fortuna.ical4j.model.component.VEvent +import org.junit.Assert.assertEquals +import org.junit.Test +import java.io.InputStreamReader + +class ICalPreprocessorTest { + + @Test + fun testMsTimeZones() { + javaClass.classLoader!!.getResourceAsStream("events/outlook1.ics").use { stream -> + val reader = InputStreamReader(stream, Charsets.UTF_8) + val calendar = CalendarBuilder().build(reader) + val vEvent = calendar.getComponent(Component.VEVENT) as VEvent + + assertEquals("W. Europe Standard Time", vEvent.startDate.timeZone.id) + ICalPreprocessor.preProcess(calendar) + assertEquals("Europe/Vienna", vEvent.startDate.timeZone.id) + } + } + +} \ No newline at end of file diff --git a/src/test/java/foundation/e/ical4android/TaskTest.kt b/src/test/java/foundation/e/ical4android/TaskTest.kt index 31150fdfe3d453bb4375f9f987a0cccd59667831..48e20df8c4bf829aa8221e6d5729467afbd15809 100644 --- a/src/test/java/foundation/e/ical4android/TaskTest.kt +++ b/src/test/java/foundation/e/ical4android/TaskTest.kt @@ -8,12 +8,12 @@ package foundation.e.ical4android -import net.fortuna.ical4j.model.Date -import net.fortuna.ical4j.model.DateList -import net.fortuna.ical4j.model.DateTime -import net.fortuna.ical4j.model.Dur +import net.fortuna.ical4j.model.* +import net.fortuna.ical4j.model.component.VAlarm +import net.fortuna.ical4j.model.parameter.RelType import net.fortuna.ical4j.model.parameter.Value import net.fortuna.ical4j.model.property.* +import net.fortuna.ical4j.util.TimeZones import org.junit.Assert.* import org.junit.Test import java.io.ByteArrayInputStream @@ -27,7 +27,7 @@ class TaskTest { @Test fun testCharsets() { - var t = parseCalendar("latin1.ics", Charset.forName("ISO-8859-1")) + var t = parseCalendar("latin1.ics", Charsets.ISO_8859_1) assertEquals("äöüß", t.summary) t = parseCalendar("utf8.ics") @@ -88,25 +88,66 @@ class TaskTest { assertEquals(828106200000L, t.createdAt) assertEquals(840288600000L, t.lastModified) + assertArrayEquals(arrayOf("Test","Sample"), t.categories.toArray()) + + val sibling = t.relatedTo.first + assertEquals("most-fields2@example.com", sibling.value) + assertEquals(RelType.SIBLING, (sibling.getParameter(Parameter.RELTYPE) as RelType)) + + val unknown = t.unknownProperties.first + assertEquals("X-UNKNOWN-PROP", unknown.name) + assertEquals("xxx", unknown.getParameter("param1").value) + 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) assertEquals(Duration(Dur(4, 3, 2, 1)), t.duration) + assertTrue(t.unknownProperties.isEmpty()) + } + + + /* generating */ + + @Test + fun testWrite() { + val t = Task() + t.uid = "SAMPLEUID" + t.dtStart = DtStart("20190101T100000", TimeZoneRegistryFactory.getInstance().createRegistry().getTimeZone("Europe/Berlin")) + + val alarm = VAlarm(Dur(0, -1, 0, 0)) + alarm.properties += Action.AUDIO + t.alarms += alarm + + val os = ByteArrayOutputStream() + t.write(os) + val raw = os.toString(Charsets.UTF_8.name()) + + assertTrue(raw.contains("PRODID:${ICalendar.prodId.value}")) + assertTrue(raw.contains("UID:SAMPLEUID")) + assertTrue(raw.contains("DTSTAMP:")) + assertTrue(raw.contains("DTSTART;TZID=Europe/Berlin:20190101T100000")) + assertTrue(raw.contains("BEGIN:VALARM\r\n" + + "TRIGGER:-PT1H\r\n" + + "ACTION:AUDIO\r\n" + + "END:VALARM\r\n")) + assertTrue(raw.contains("BEGIN:VTIMEZONE")) } /* helpers */ private fun parseCalendar(fname: String, charset: Charset = Charsets.UTF_8): Task { - javaClass.classLoader.getResourceAsStream("tasks/$fname").use { stream -> - return Task.fromReader(InputStreamReader(stream, charset)).first() + javaClass.classLoader!!.getResourceAsStream("tasks/$fname").use { stream -> + return Task.tasksFromReader(InputStreamReader(stream, charset)).first() } } private fun regenerate(t: Task): Task { val os = ByteArrayOutputStream() t.write(os) - return Task.fromReader(InputStreamReader(ByteArrayInputStream(os.toByteArray()), Charsets.UTF_8)).first() + return Task.tasksFromReader(InputStreamReader(ByteArrayInputStream(os.toByteArray()), Charsets.UTF_8)).first() } } diff --git a/src/test/resources/events/outlook1.ics b/src/test/resources/events/outlook1.ics new file mode 100644 index 0000000000000000000000000000000000000000..000afaa24b7be68f1438fb834bc0ee4f37e1a304 --- /dev/null +++ b/src/test/resources/events/outlook1.ics @@ -0,0 +1,72 @@ +BEGIN:VCALENDAR +METHOD:PUBLISH +PRODID:Microsoft Exchange Server 2010 +VERSION:2.0 +X-WR-CALNAME:Calendar +BEGIN:VTIMEZONE +TZID:China Standard Time +BEGIN:STANDARD +DTSTART:16010101T000000 +TZOFFSETFROM:+0800 +TZOFFSETTO:+0800 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:16010101T000000 +TZOFFSETFROM:+0800 +TZOFFSETTO:+0800 +END:DAYLIGHT +END:VTIMEZONE +BEGIN:VTIMEZONE +TZID:W. Europe Standard Time +BEGIN:STANDARD +DTSTART:16010101T030000 +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=10 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:16010101T020000 +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=3 +END:DAYLIGHT +END:VTIMEZONE +BEGIN:VTIMEZONE +TZID:India Standard Time +BEGIN:STANDARD +DTSTART:16010101T000000 +TZOFFSETFROM:+0530 +TZOFFSETTO:+0530 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:16010101T000000 +TZOFFSETFROM:+0530 +TZOFFSETTO:+0530 +END:DAYLIGHT +END:VTIMEZONE +BEGIN:VEVENT +DESCRIPTION:\n............................................................. + .................................\n\n\n +RRULE:FREQ=WEEKLY;UNTIL=20191218T080000Z;INTERVAL=1;BYDAY=WE;WKST=MO +UID:040000008200E00074C5B7101A82E00800000000907682BE2B88D501000000000000000 + 01000000077F6CDA3B634104B9BC1ED539119F558 +SUMMARY:Weekly Meeting - Test +DTSTART;TZID=W. Europe Standard Time:20191023T090000 +DTEND;TZID=W. Europe Standard Time:20191023T093000 +CLASS:PUBLIC +PRIORITY:5 +DTSTAMP:20191119T090349Z +TRANSP:OPAQUE +STATUS:CONFIRMED +SEQUENCE:1 +LOCATION:Skype-Besprechung +X-MICROSOFT-CDO-APPT-SEQUENCE:1 +X-MICROSOFT-CDO-BUSYSTATUS:BUSY +X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY +X-MICROSOFT-CDO-ALLDAYEVENT:FALSE +X-MICROSOFT-CDO-IMPORTANCE:1 +X-MICROSOFT-CDO-INSTTYPE:1 +X-MICROSOFT-DONOTFORWARDMEETING:FALSE +X-MICROSOFT-DISALLOW-COUNTER:FALSE +END:VEVENT +END:VCALENDAR diff --git a/src/test/resources/events/utf8.ics b/src/test/resources/events/utf8.ics index 43fbdbab5058cd477ea43c6e10de9375febae12d..87171a9b06319dda8b8bccceb247b260caea24f5 100644 --- a/src/test/resources/events/utf8.ics +++ b/src/test/resources/events/utf8.ics @@ -7,6 +7,8 @@ SUMMARY:© äö — üß 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 diff --git a/src/test/resources/tasks/most-fields1.ics b/src/test/resources/tasks/most-fields1.ics index fdf002f49386a0eae43f5a32aa4a9d87d34bf217..ff4dbb04601424d6df79a17b37383cd357f132ce 100644 --- a/src/test/resources/tasks/most-fields1.ics +++ b/src/test/resources/tasks/most-fields1.ics @@ -18,11 +18,14 @@ 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 +RELATED-TO;RELTYPE=SIBLING:most-fields2@example.com +X-UNKNOWN-PROP;param1=xxx:Unknown Value CREATED:19960329T133000Z LAST-MODIFIED:19960817T133000Z END:VTODO diff --git a/src/test/resources/tasks/rfc5545-sample1.ics b/src/test/resources/tasks/rfc5545-sample1.ics index 7f797ad41324f080ae464cc9b6fa21a3330baa5f..18d53dfe1cb15e75d92b03c357dd6148a98af16d 100644 --- a/src/test/resources/tasks/rfc5545-sample1.ics +++ b/src/test/resources/tasks/rfc5545-sample1.ics @@ -12,7 +12,7 @@ STATUS:NEEDS-ACTION SUMMARY:Submit Income Taxes BEGIN:VALARM ACTION:AUDIO -TRIGGER:19980403T120000Z +TRIGGER;VALUE=DATE-TIME:19980403T120000Z ATTACH;FMTTYPE=audio/basic:http://example.com/pub/audio- files/ssbanner.aud REPEAT:4