diff --git a/lib/src/androidTest/kotlin/at/bitfire/ical4android/Css3ColorTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/Css3ColorTest.kt deleted file mode 100644 index 6a0daec6..00000000 --- a/lib/src/androidTest/kotlin/at/bitfire/ical4android/Css3ColorTest.kt +++ /dev/null @@ -1,54 +0,0 @@ -/* - * This file is part of bitfireAT/synctools which is released under GPLv3. - * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package at.bitfire.ical4android - -import at.bitfire.synctools.icalendar.Css3Color -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Test - -class Css3ColorTest { - - @Test - fun testColorFromString() { - // color name - assertEquals(0xffffff00.toInt(), Css3Color.colorFromString("yellow")) - - // RGB value - assertEquals(0xffffff00.toInt(), Css3Color.colorFromString("#ffff00")) - - // ARGB value - assertEquals(0xffffff00.toInt(), Css3Color.colorFromString("#ffffff00")) - - // empty value - assertNull(Css3Color.colorFromString("")) - - // invalid value - assertNull(Css3Color.colorFromString("DoesNotExist")) - } - - @Test - fun testFromString() { - // lower case - assertEquals(0xffffff00.toInt(), Css3Color.fromString("yellow")?.argb) - - // capitalized - assertEquals(0xffffff00.toInt(), Css3Color.fromString("Yellow")?.argb) - - // not-existing color - assertNull(Css3Color.fromString("DoesNotExist")) - } - - @Test - fun testNearestMatch() { - // every color is its own nearest match - Css3Color.entries.forEach { - assertEquals(it.argb, Css3Color.nearestMatch(it.argb).argb) - } - } - -} \ No newline at end of file diff --git a/lib/src/androidTest/kotlin/at/bitfire/ical4android/DmfsTaskTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/DmfsTaskTest.kt index 50b6fc9b..940d0f82 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/ical4android/DmfsTaskTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/DmfsTaskTest.kt @@ -15,6 +15,7 @@ import at.bitfire.ical4android.impl.TestTask import at.bitfire.ical4android.impl.TestTaskList import at.bitfire.ical4android.util.DateUtils import at.bitfire.synctools.storage.LocalStorageException +import at.bitfire.synctools.storage.calendar.UnknownProperty import net.fortuna.ical4j.model.Date import net.fortuna.ical4j.model.DateList import net.fortuna.ical4j.model.DateTime diff --git a/lib/src/androidTest/kotlin/at/bitfire/ical4android/AttendeeMappingsTest.kt b/lib/src/androidTest/kotlin/at/bitfire/synctools/calendar/AttendeeMappingsTest.kt similarity index 51% rename from lib/src/androidTest/kotlin/at/bitfire/ical4android/AttendeeMappingsTest.kt rename to lib/src/androidTest/kotlin/at/bitfire/synctools/calendar/AttendeeMappingsTest.kt index cc07ab86..926651f6 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/ical4android/AttendeeMappingsTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/synctools/calendar/AttendeeMappingsTest.kt @@ -4,11 +4,13 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -package at.bitfire.ical4android +package at.bitfire.synctools.calendar import android.content.ContentValues import android.net.Uri import android.provider.CalendarContract.Attendees +import androidx.core.content.contentValuesOf +import at.bitfire.synctools.mapping.calendar.AttendeeMappings import at.bitfire.synctools.storage.BatchOperation import net.fortuna.ical4j.model.Parameter import net.fortuna.ical4j.model.parameter.CuType @@ -30,10 +32,10 @@ class AttendeeMappingsTest { @Test fun testAndroidToICalendar_TypeRequired_RelationshipAttendee() { - testAndroidToICalendar(ContentValues().apply { - put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_REQUIRED) - put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_ATTENDEE) - }) { + testAndroidToICalendar(contentValuesOf( + Attendees.ATTENDEE_TYPE to Attendees.TYPE_REQUIRED, + Attendees.ATTENDEE_RELATIONSHIP to Attendees.RELATIONSHIP_ATTENDEE + )) { assertNull(getParameter(Parameter.CUTYPE)) assertNull(getParameter(Parameter.ROLE)) } @@ -41,10 +43,10 @@ class AttendeeMappingsTest { @Test fun testAndroidToICalendar_TypeRequired_RelationshipOrganizer() { - testAndroidToICalendar(ContentValues().apply { - put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_REQUIRED) - put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_ORGANIZER) - }) { + testAndroidToICalendar(contentValuesOf( + Attendees.ATTENDEE_TYPE to Attendees.TYPE_REQUIRED, + Attendees.ATTENDEE_RELATIONSHIP to Attendees.RELATIONSHIP_ORGANIZER + )) { assertNull(getParameter(Parameter.CUTYPE)) assertNull(getParameter(Parameter.ROLE)) } @@ -52,10 +54,10 @@ class AttendeeMappingsTest { @Test fun testAndroidToICalendar_TypeRequired_RelationshipPerformer() { - testAndroidToICalendar(ContentValues().apply { - put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_REQUIRED) - put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_PERFORMER) - }) { + testAndroidToICalendar(contentValuesOf( + Attendees.ATTENDEE_TYPE to Attendees.TYPE_REQUIRED, + Attendees.ATTENDEE_RELATIONSHIP to Attendees.RELATIONSHIP_PERFORMER + )) { assertEquals(CuType.GROUP, getParameter(Parameter.CUTYPE)) assertNull(getParameter(Parameter.ROLE)) } @@ -63,10 +65,10 @@ class AttendeeMappingsTest { @Test fun testAndroidToICalendar_TypeRequired_RelationshipSpeaker() { - testAndroidToICalendar(ContentValues().apply { - put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_REQUIRED) - put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_SPEAKER) - }) { + testAndroidToICalendar(contentValuesOf( + Attendees.ATTENDEE_TYPE to Attendees.TYPE_REQUIRED, + Attendees.ATTENDEE_RELATIONSHIP to Attendees.RELATIONSHIP_SPEAKER + )) { assertNull(getParameter(Parameter.CUTYPE)) assertEquals(Role.CHAIR, getParameter(Parameter.ROLE)) } @@ -74,10 +76,10 @@ class AttendeeMappingsTest { @Test fun testAndroidToICalendar_TypeRequired_RelationshipNone() { - testAndroidToICalendar(ContentValues().apply { - put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_REQUIRED) - put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_NONE) - }) { + testAndroidToICalendar(contentValuesOf( + Attendees.ATTENDEE_TYPE to Attendees.TYPE_REQUIRED, + Attendees.ATTENDEE_RELATIONSHIP to Attendees.RELATIONSHIP_NONE + )) { assertEquals(CuType.UNKNOWN, getParameter(Parameter.CUTYPE)) assertNull(getParameter(Parameter.ROLE)) } @@ -86,10 +88,10 @@ class AttendeeMappingsTest { @Test fun testAndroidToICalendar_TypeOptional_RelationshipAttendee() { - testAndroidToICalendar(ContentValues().apply { - put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_OPTIONAL) - put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_ATTENDEE) - }) { + testAndroidToICalendar(contentValuesOf( + Attendees.ATTENDEE_TYPE to Attendees.TYPE_OPTIONAL, + Attendees.ATTENDEE_RELATIONSHIP to Attendees.RELATIONSHIP_ATTENDEE + )) { assertNull(getParameter(Parameter.CUTYPE)) assertEquals(Role.OPT_PARTICIPANT, getParameter(Parameter.ROLE)) } @@ -97,10 +99,10 @@ class AttendeeMappingsTest { @Test fun testAndroidToICalendar_TypeOptional_RelationshipOrganizer() { - testAndroidToICalendar(ContentValues().apply { - put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_OPTIONAL) - put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_ORGANIZER) - }) { + testAndroidToICalendar(contentValuesOf( + Attendees.ATTENDEE_TYPE to Attendees.TYPE_OPTIONAL, + Attendees.ATTENDEE_RELATIONSHIP to Attendees.RELATIONSHIP_ORGANIZER + )) { assertNull(getParameter(Parameter.CUTYPE)) assertEquals(Role.OPT_PARTICIPANT, getParameter(Parameter.ROLE)) } @@ -108,10 +110,10 @@ class AttendeeMappingsTest { @Test fun testAndroidToICalendar_TypeOptional_RelationshipPerformer() { - testAndroidToICalendar(ContentValues().apply { - put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_OPTIONAL) - put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_PERFORMER) - }) { + testAndroidToICalendar(contentValuesOf( + Attendees.ATTENDEE_TYPE to Attendees.TYPE_OPTIONAL, + Attendees.ATTENDEE_RELATIONSHIP to Attendees.RELATIONSHIP_PERFORMER + )) { assertEquals(CuType.GROUP, getParameter(Parameter.CUTYPE)) assertEquals(Role.OPT_PARTICIPANT, getParameter(Parameter.ROLE)) } @@ -119,10 +121,10 @@ class AttendeeMappingsTest { @Test fun testAndroidToICalendar_TypeOptional_RelationshipSpeaker() { - testAndroidToICalendar(ContentValues().apply { - put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_OPTIONAL) - put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_SPEAKER) - }) { + testAndroidToICalendar(contentValuesOf( + Attendees.ATTENDEE_TYPE to Attendees.TYPE_OPTIONAL, + Attendees.ATTENDEE_RELATIONSHIP to Attendees.RELATIONSHIP_SPEAKER + )) { assertNull(getParameter(Parameter.CUTYPE)) assertEquals(Role.CHAIR, getParameter(Parameter.ROLE)) } @@ -130,10 +132,10 @@ class AttendeeMappingsTest { @Test fun testAndroidToICalendar_TypeOptional_RelationshipNone() { - testAndroidToICalendar(ContentValues().apply { - put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_OPTIONAL) - put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_NONE) - }) { + testAndroidToICalendar(contentValuesOf( + Attendees.ATTENDEE_TYPE to Attendees.TYPE_OPTIONAL, + Attendees.ATTENDEE_RELATIONSHIP to Attendees.RELATIONSHIP_NONE + )) { assertEquals(CuType.UNKNOWN, getParameter(Parameter.CUTYPE)) assertEquals(Role.OPT_PARTICIPANT, getParameter(Parameter.ROLE)) } @@ -142,10 +144,10 @@ class AttendeeMappingsTest { @Test fun testAndroidToICalendar_TypeNone_RelationshipAttendee() { - testAndroidToICalendar(ContentValues().apply { - put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_NONE) - put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_ATTENDEE) - }) { + testAndroidToICalendar(contentValuesOf( + Attendees.ATTENDEE_TYPE to Attendees.TYPE_NONE, + Attendees.ATTENDEE_RELATIONSHIP to Attendees.RELATIONSHIP_ATTENDEE + )) { assertNull(getParameter(Parameter.CUTYPE)) assertNull(getParameter(Parameter.ROLE)) } @@ -153,10 +155,10 @@ class AttendeeMappingsTest { @Test fun testAndroidToICalendar_TypeNone_RelationshipOrganizer() { - testAndroidToICalendar(ContentValues().apply { - put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_NONE) - put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_ORGANIZER) - }) { + testAndroidToICalendar(contentValuesOf( + Attendees.ATTENDEE_TYPE to Attendees.TYPE_NONE, + Attendees.ATTENDEE_RELATIONSHIP to Attendees.RELATIONSHIP_ORGANIZER + )) { assertNull(getParameter(Parameter.CUTYPE)) assertNull(getParameter(Parameter.ROLE)) } @@ -164,10 +166,10 @@ class AttendeeMappingsTest { @Test fun testAndroidToICalendar_TypeNone_RelationshipPerformer() { - testAndroidToICalendar(ContentValues().apply { - put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_NONE) - put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_PERFORMER) - }) { + testAndroidToICalendar(contentValuesOf( + Attendees.ATTENDEE_TYPE to Attendees.TYPE_NONE, + Attendees.ATTENDEE_RELATIONSHIP to Attendees.RELATIONSHIP_PERFORMER + )) { assertEquals(CuType.GROUP, getParameter(Parameter.CUTYPE)) assertNull(getParameter(Parameter.ROLE)) } @@ -175,10 +177,10 @@ class AttendeeMappingsTest { @Test fun testAndroidToICalendar_TypeNone_RelationshipSpeaker() { - testAndroidToICalendar(ContentValues().apply { - put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_NONE) - put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_SPEAKER) - }) { + testAndroidToICalendar(contentValuesOf( + Attendees.ATTENDEE_TYPE to Attendees.TYPE_NONE, + Attendees.ATTENDEE_RELATIONSHIP to Attendees.RELATIONSHIP_SPEAKER + )) { assertNull(getParameter(Parameter.CUTYPE)) assertEquals(Role.CHAIR, getParameter(Parameter.ROLE)) } @@ -186,10 +188,10 @@ class AttendeeMappingsTest { @Test fun testAndroidToICalendar_TypeNone_RelationshipNone() { - testAndroidToICalendar(ContentValues().apply { - put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_NONE) - put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_NONE) - }) { + testAndroidToICalendar(contentValuesOf( + Attendees.ATTENDEE_TYPE to Attendees.TYPE_NONE, + Attendees.ATTENDEE_RELATIONSHIP to Attendees.RELATIONSHIP_NONE + )) { assertEquals(CuType.UNKNOWN, getParameter(Parameter.CUTYPE)) assertNull(getParameter(Parameter.ROLE)) } @@ -198,10 +200,10 @@ class AttendeeMappingsTest { @Test fun testAndroidToICalendar_TypeResource_RelationshipAttendee() { - testAndroidToICalendar(ContentValues().apply { - put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_RESOURCE) - put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_ATTENDEE) - }) { + testAndroidToICalendar(contentValuesOf( + Attendees.ATTENDEE_TYPE to Attendees.TYPE_RESOURCE, + Attendees.ATTENDEE_RELATIONSHIP to Attendees.RELATIONSHIP_ATTENDEE + )) { assertEquals(CuType.RESOURCE, getParameter(Parameter.CUTYPE)) assertNull(getParameter(Parameter.ROLE)) } @@ -209,10 +211,10 @@ class AttendeeMappingsTest { @Test fun testAndroidToICalendar_TypeResource_RelationshipOrganizer() { - testAndroidToICalendar(ContentValues().apply { - put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_RESOURCE) - put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_ORGANIZER) - }) { + testAndroidToICalendar(contentValuesOf( + Attendees.ATTENDEE_TYPE to Attendees.TYPE_RESOURCE, + Attendees.ATTENDEE_RELATIONSHIP to Attendees.RELATIONSHIP_ORGANIZER + )) { assertEquals(CuType.RESOURCE, getParameter(Parameter.CUTYPE)) assertNull(getParameter(Parameter.ROLE)) } @@ -220,10 +222,10 @@ class AttendeeMappingsTest { @Test fun testAndroidToICalendar_TypeResource_RelationshipPerformer() { - testAndroidToICalendar(ContentValues().apply { - put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_RESOURCE) - put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_PERFORMER) - }) { + testAndroidToICalendar(contentValuesOf( + Attendees.ATTENDEE_TYPE to Attendees.TYPE_RESOURCE, + Attendees.ATTENDEE_RELATIONSHIP to Attendees.RELATIONSHIP_PERFORMER + )) { assertEquals(CuType.ROOM, getParameter(Parameter.CUTYPE)) assertNull(getParameter(Parameter.ROLE)) } @@ -231,10 +233,10 @@ class AttendeeMappingsTest { @Test fun testAndroidToICalendar_TypeResource_RelationshipSpeaker() { - testAndroidToICalendar(ContentValues().apply { - put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_RESOURCE) - put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_SPEAKER) - }) { + testAndroidToICalendar(contentValuesOf( + Attendees.ATTENDEE_TYPE to Attendees.TYPE_RESOURCE, + Attendees.ATTENDEE_RELATIONSHIP to Attendees.RELATIONSHIP_SPEAKER + )) { assertEquals(CuType.RESOURCE, getParameter(Parameter.CUTYPE)) assertEquals(Role.CHAIR, getParameter(Parameter.ROLE)) } @@ -242,10 +244,10 @@ class AttendeeMappingsTest { @Test fun testAndroidToICalendar_TypeResource_RelationshipNone() { - testAndroidToICalendar(ContentValues().apply { - put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_RESOURCE) - put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_NONE) - }) { + testAndroidToICalendar(contentValuesOf( + Attendees.ATTENDEE_TYPE to Attendees.TYPE_RESOURCE, + Attendees.ATTENDEE_RELATIONSHIP to Attendees.RELATIONSHIP_NONE + )) { assertEquals(CuType.RESOURCE, getParameter(Parameter.CUTYPE)) assertNull(getParameter(Parameter.ROLE)) } @@ -256,8 +258,14 @@ class AttendeeMappingsTest { @Test fun testICalendarToAndroid_CuTypeNone_RoleNone() { testICalendarToAndroid(Attendee("mailto:attendee@example.com")) { - assertEquals(Attendees.TYPE_REQUIRED, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_ATTENDEE, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_REQUIRED, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_ATTENDEE, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @@ -266,8 +274,14 @@ class AttendeeMappingsTest { testICalendarToAndroid(Attendee("mailto:attendee@example.com").apply { parameters.add(Role.CHAIR) }) { - assertEquals(Attendees.TYPE_REQUIRED, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_SPEAKER, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_REQUIRED, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_SPEAKER, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @@ -276,8 +290,14 @@ class AttendeeMappingsTest { testICalendarToAndroid(Attendee("mailto:attendee@example.com").apply { parameters.add(Role.REQ_PARTICIPANT) }) { - assertEquals(Attendees.TYPE_REQUIRED, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_ATTENDEE, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_REQUIRED, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_ATTENDEE, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @@ -286,8 +306,14 @@ class AttendeeMappingsTest { testICalendarToAndroid(Attendee("mailto:attendee@example.com").apply { parameters.add(Role.OPT_PARTICIPANT) }) { - assertEquals(Attendees.TYPE_OPTIONAL, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_ATTENDEE, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_OPTIONAL, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_ATTENDEE, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @@ -296,8 +322,14 @@ class AttendeeMappingsTest { testICalendarToAndroid(Attendee("mailto:attendee@example.com").apply { parameters.add(Role.NON_PARTICIPANT) }) { - assertEquals(Attendees.TYPE_NONE, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_ATTENDEE, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_NONE, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_ATTENDEE, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @@ -306,8 +338,14 @@ class AttendeeMappingsTest { testICalendarToAndroid(Attendee("mailto:attendee@example.com").apply { parameters.add(RoleFancy) }) { - assertEquals(Attendees.TYPE_REQUIRED, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_ATTENDEE, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_REQUIRED, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_ATTENDEE, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @@ -317,8 +355,14 @@ class AttendeeMappingsTest { testICalendarToAndroid(Attendee("mailto:attendee@example.com").apply { parameters.add(CuType.INDIVIDUAL) }) { - assertEquals(Attendees.TYPE_REQUIRED, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_ATTENDEE, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_REQUIRED, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_ATTENDEE, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @@ -328,8 +372,14 @@ class AttendeeMappingsTest { parameters.add(CuType.INDIVIDUAL) parameters.add(Role.CHAIR) }) { - assertEquals(Attendees.TYPE_REQUIRED, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_SPEAKER, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_REQUIRED, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_SPEAKER, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @@ -339,8 +389,14 @@ class AttendeeMappingsTest { parameters.add(CuType.INDIVIDUAL) parameters.add(Role.REQ_PARTICIPANT) }) { - assertEquals(Attendees.TYPE_REQUIRED, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_ATTENDEE, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_REQUIRED, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_ATTENDEE, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @@ -350,8 +406,14 @@ class AttendeeMappingsTest { parameters.add(CuType.INDIVIDUAL) parameters.add(Role.OPT_PARTICIPANT) }) { - assertEquals(Attendees.TYPE_OPTIONAL, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_ATTENDEE, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_OPTIONAL, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_ATTENDEE, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @@ -361,8 +423,14 @@ class AttendeeMappingsTest { parameters.add(CuType.INDIVIDUAL) parameters.add(Role.NON_PARTICIPANT) }) { - assertEquals(Attendees.TYPE_NONE, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_ATTENDEE, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_NONE, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_ATTENDEE, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @@ -372,8 +440,14 @@ class AttendeeMappingsTest { parameters.add(CuType.INDIVIDUAL) parameters.add(RoleFancy) }) { - assertEquals(Attendees.TYPE_REQUIRED, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_ATTENDEE, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_REQUIRED, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_ATTENDEE, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @@ -383,8 +457,14 @@ class AttendeeMappingsTest { testICalendarToAndroid(Attendee("mailto:attendee@example.com").apply { parameters.add(CuType.UNKNOWN) }) { - assertEquals(Attendees.TYPE_REQUIRED, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_NONE, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_REQUIRED, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_NONE, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @@ -394,8 +474,14 @@ class AttendeeMappingsTest { parameters.add(CuType.UNKNOWN) parameters.add(Role.CHAIR) }) { - assertEquals(Attendees.TYPE_REQUIRED, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_SPEAKER, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_REQUIRED, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_SPEAKER, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @@ -405,8 +491,14 @@ class AttendeeMappingsTest { parameters.add(CuType.UNKNOWN) parameters.add(Role.REQ_PARTICIPANT) }) { - assertEquals(Attendees.TYPE_REQUIRED, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_NONE, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_REQUIRED, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_NONE, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @@ -416,8 +508,14 @@ class AttendeeMappingsTest { parameters.add(CuType.UNKNOWN) parameters.add(Role.OPT_PARTICIPANT) }) { - assertEquals(Attendees.TYPE_OPTIONAL, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_NONE, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_OPTIONAL, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_NONE, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @@ -427,8 +525,14 @@ class AttendeeMappingsTest { parameters.add(CuType.UNKNOWN) parameters.add(Role.NON_PARTICIPANT) }) { - assertEquals(Attendees.TYPE_NONE, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_NONE, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_NONE, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_NONE, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @@ -438,8 +542,14 @@ class AttendeeMappingsTest { parameters.add(CuType.UNKNOWN) parameters.add(RoleFancy) }) { - assertEquals(Attendees.TYPE_REQUIRED, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_NONE, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_REQUIRED, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_NONE, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @@ -449,8 +559,14 @@ class AttendeeMappingsTest { testICalendarToAndroid(Attendee("mailto:attendee@example.com").apply { parameters.add(CuType.GROUP) }) { - assertEquals(Attendees.TYPE_REQUIRED, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_PERFORMER, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_REQUIRED, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_PERFORMER, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @@ -460,8 +576,14 @@ class AttendeeMappingsTest { parameters.add(CuType.GROUP) parameters.add(Role.CHAIR) }) { - assertEquals(Attendees.TYPE_REQUIRED, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_SPEAKER, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_REQUIRED, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_SPEAKER, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @@ -471,8 +593,14 @@ class AttendeeMappingsTest { parameters.add(CuType.GROUP) parameters.add(Role.REQ_PARTICIPANT) }) { - assertEquals(Attendees.TYPE_REQUIRED, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_PERFORMER, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_REQUIRED, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_PERFORMER, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @@ -482,8 +610,14 @@ class AttendeeMappingsTest { parameters.add(CuType.GROUP) parameters.add(Role.OPT_PARTICIPANT) }) { - assertEquals(Attendees.TYPE_OPTIONAL, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_PERFORMER, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_OPTIONAL, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_PERFORMER, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @@ -493,8 +627,14 @@ class AttendeeMappingsTest { parameters.add(CuType.GROUP) parameters.add(Role.NON_PARTICIPANT) }) { - assertEquals(Attendees.TYPE_NONE, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_PERFORMER, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_NONE, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_PERFORMER, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @@ -504,8 +644,14 @@ class AttendeeMappingsTest { parameters.add(CuType.GROUP) parameters.add(RoleFancy) }) { - assertEquals(Attendees.TYPE_REQUIRED, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_PERFORMER, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_REQUIRED, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_PERFORMER, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @@ -515,8 +661,14 @@ class AttendeeMappingsTest { testICalendarToAndroid(Attendee("mailto:attendee@example.com").apply { parameters.add(CuType.RESOURCE) }) { - assertEquals(Attendees.TYPE_RESOURCE, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_NONE, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_RESOURCE, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_NONE, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @@ -526,8 +678,14 @@ class AttendeeMappingsTest { parameters.add(CuType.RESOURCE) parameters.add(Role.CHAIR) }) { - assertEquals(Attendees.TYPE_RESOURCE, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_SPEAKER, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_RESOURCE, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_SPEAKER, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @@ -537,8 +695,14 @@ class AttendeeMappingsTest { parameters.add(CuType.RESOURCE) parameters.add(Role.REQ_PARTICIPANT) }) { - assertEquals(Attendees.TYPE_RESOURCE, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_NONE, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_RESOURCE, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_NONE, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @@ -548,8 +712,14 @@ class AttendeeMappingsTest { parameters.add(CuType.RESOURCE) parameters.add(Role.OPT_PARTICIPANT) }) { - assertEquals(Attendees.TYPE_RESOURCE, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_NONE, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_RESOURCE, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_NONE, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @@ -559,8 +729,14 @@ class AttendeeMappingsTest { parameters.add(CuType.RESOURCE) parameters.add(Role.NON_PARTICIPANT) }) { - assertEquals(Attendees.TYPE_RESOURCE, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_NONE, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_RESOURCE, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_NONE, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @@ -570,8 +746,14 @@ class AttendeeMappingsTest { parameters.add(CuType.RESOURCE) parameters.add(RoleFancy) }) { - assertEquals(Attendees.TYPE_RESOURCE, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_NONE, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_RESOURCE, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_NONE, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @@ -581,8 +763,14 @@ class AttendeeMappingsTest { testICalendarToAndroid(Attendee("mailto:attendee@example.com").apply { parameters.add(CuType.ROOM) }) { - assertEquals(Attendees.TYPE_RESOURCE, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_PERFORMER, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_RESOURCE, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_PERFORMER, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @@ -592,8 +780,14 @@ class AttendeeMappingsTest { parameters.add(CuType.ROOM) parameters.add(Role.CHAIR) }) { - assertEquals(Attendees.TYPE_RESOURCE, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_PERFORMER, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_RESOURCE, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_PERFORMER, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @@ -603,8 +797,14 @@ class AttendeeMappingsTest { parameters.add(CuType.ROOM) parameters.add(Role.REQ_PARTICIPANT) }) { - assertEquals(Attendees.TYPE_RESOURCE, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_PERFORMER, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_RESOURCE, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_PERFORMER, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @@ -614,8 +814,14 @@ class AttendeeMappingsTest { parameters.add(CuType.ROOM) parameters.add(Role.OPT_PARTICIPANT) }) { - assertEquals(Attendees.TYPE_RESOURCE, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_PERFORMER, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_RESOURCE, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_PERFORMER, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @@ -625,8 +831,14 @@ class AttendeeMappingsTest { parameters.add(CuType.ROOM) parameters.add(Role.NON_PARTICIPANT) }) { - assertEquals(Attendees.TYPE_RESOURCE, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_PERFORMER, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_RESOURCE, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_PERFORMER, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @@ -636,8 +848,14 @@ class AttendeeMappingsTest { parameters.add(CuType.ROOM) parameters.add(RoleFancy) }) { - assertEquals(Attendees.TYPE_RESOURCE, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_PERFORMER, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_RESOURCE, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_PERFORMER, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @@ -647,8 +865,14 @@ class AttendeeMappingsTest { testICalendarToAndroid(Attendee("mailto:attendee@example.com").apply { parameters.add(CuTypeFancy) }) { - assertEquals(Attendees.TYPE_REQUIRED, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_ATTENDEE, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_REQUIRED, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_ATTENDEE, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @@ -658,8 +882,14 @@ class AttendeeMappingsTest { parameters.add(CuTypeFancy) parameters.add(Role.CHAIR) }) { - assertEquals(Attendees.TYPE_REQUIRED, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_SPEAKER, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_REQUIRED, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_SPEAKER, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @@ -669,8 +899,14 @@ class AttendeeMappingsTest { parameters.add(CuTypeFancy) parameters.add(Role.REQ_PARTICIPANT) }) { - assertEquals(Attendees.TYPE_REQUIRED, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_ATTENDEE, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_REQUIRED, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_ATTENDEE, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @@ -680,8 +916,14 @@ class AttendeeMappingsTest { parameters.add(CuTypeFancy) parameters.add(Role.OPT_PARTICIPANT) }) { - assertEquals(Attendees.TYPE_OPTIONAL, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_ATTENDEE, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_OPTIONAL, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_ATTENDEE, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @@ -691,8 +933,14 @@ class AttendeeMappingsTest { parameters.add(CuTypeFancy) parameters.add(Role.NON_PARTICIPANT) }) { - assertEquals(Attendees.TYPE_NONE, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_ATTENDEE, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_NONE, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_ATTENDEE, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @@ -702,8 +950,14 @@ class AttendeeMappingsTest { parameters.add(CuTypeFancy) parameters.add(RoleFancy) }) { - assertEquals(Attendees.TYPE_REQUIRED, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_ATTENDEE, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_REQUIRED, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_ATTENDEE, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @@ -711,7 +965,10 @@ class AttendeeMappingsTest { @Test fun testICalendarToAndroid_Organizer() { testICalendarToAndroid(Attendee("mailto:$DEFAULT_ORGANIZER")) { - assertEquals(Attendees.RELATIONSHIP_ORGANIZER, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.RELATIONSHIP_ORGANIZER, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } diff --git a/lib/src/androidTest/kotlin/at/bitfire/synctools/icalendar/Css3ColorTest.kt b/lib/src/androidTest/kotlin/at/bitfire/synctools/icalendar/Css3ColorTest.kt new file mode 100644 index 00000000..1eb4b982 --- /dev/null +++ b/lib/src/androidTest/kotlin/at/bitfire/synctools/icalendar/Css3ColorTest.kt @@ -0,0 +1,52 @@ +/* + * This file is part of bitfireAT/synctools which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.synctools.icalendar + +import org.junit.Assert +import org.junit.Test + +class Css3ColorTest { + + @Test + fun testColorFromString() { + // color name + Assert.assertEquals(0xffffff00.toInt(), Css3Color.colorFromString("yellow")) + + // RGB value + Assert.assertEquals(0xffffff00.toInt(), Css3Color.colorFromString("#ffff00")) + + // ARGB value + Assert.assertEquals(0xffffff00.toInt(), Css3Color.colorFromString("#ffffff00")) + + // empty value + Assert.assertNull(Css3Color.colorFromString("")) + + // invalid value + Assert.assertNull(Css3Color.colorFromString("DoesNotExist")) + } + + @Test + fun testFromString() { + // lower case + Assert.assertEquals(0xffffff00.toInt(), Css3Color.fromString("yellow")?.argb) + + // capitalized + Assert.assertEquals(0xffffff00.toInt(), Css3Color.fromString("Yellow")?.argb) + + // not-existing color + Assert.assertNull(Css3Color.fromString("DoesNotExist")) + } + + @Test + fun testNearestMatch() { + // every color is its own nearest match + Css3Color.entries.forEach { + Assert.assertEquals(it.argb, Css3Color.nearestMatch(it.argb).argb) + } + } + +} \ No newline at end of file diff --git a/lib/src/androidTest/kotlin/at/bitfire/ical4android/ICalPreprocessorTest.kt b/lib/src/androidTest/kotlin/at/bitfire/synctools/icalendar/validation/ICalPreprocessorTest.kt similarity index 75% rename from lib/src/androidTest/kotlin/at/bitfire/ical4android/ICalPreprocessorTest.kt rename to lib/src/androidTest/kotlin/at/bitfire/synctools/icalendar/validation/ICalPreprocessorTest.kt index 71a435e5..b3b13369 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/ical4android/ICalPreprocessorTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/synctools/icalendar/validation/ICalPreprocessorTest.kt @@ -4,18 +4,14 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -package at.bitfire.ical4android +package at.bitfire.synctools.icalendar.validation -import androidx.test.filters.SdkSuppress -import at.bitfire.ical4android.validation.FixInvalidDayOffsetPreprocessor -import at.bitfire.ical4android.validation.FixInvalidUtcOffsetPreprocessor -import at.bitfire.ical4android.validation.ICalPreprocessor import io.mockk.mockkObject import io.mockk.verify 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.Assert import org.junit.Test import java.io.InputStreamReader import java.io.StringReader @@ -23,7 +19,6 @@ import java.io.StringReader class ICalPreprocessorTest { @Test - @SdkSuppress(minSdkVersion = 28) fun testPreprocessStream_appliesStreamProcessors() { // Can only run on API Level 28 or newer because mockkObject doesn't support Android < P mockkObject(FixInvalidDayOffsetPreprocessor, FixInvalidUtcOffsetPreprocessor) { @@ -45,9 +40,9 @@ class ICalPreprocessorTest { val calendar = CalendarBuilder().build(reader) val vEvent = calendar.getComponent(Component.VEVENT) as VEvent - assertEquals("W. Europe Standard Time", vEvent.startDate.timeZone.id) + Assert.assertEquals("W. Europe Standard Time", vEvent.startDate.timeZone.id) ICalPreprocessor.preprocessCalendar(calendar) - assertEquals("Europe/Vienna", vEvent.startDate.timeZone.id) + Assert.assertEquals("Europe/Vienna", vEvent.startDate.timeZone.id) } } diff --git a/lib/src/androidTest/kotlin/at/bitfire/synctools/storage/calendar/AndroidCalendarProviderTest.kt b/lib/src/androidTest/kotlin/at/bitfire/synctools/storage/calendar/AndroidCalendarProviderTest.kt new file mode 100644 index 00000000..210027b6 --- /dev/null +++ b/lib/src/androidTest/kotlin/at/bitfire/synctools/storage/calendar/AndroidCalendarProviderTest.kt @@ -0,0 +1,95 @@ +/* + * This file is part of bitfireAT/synctools which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.synctools.storage.calendar + +import android.Manifest +import android.accounts.Account +import android.content.ContentProviderClient +import android.provider.CalendarContract +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import at.bitfire.ical4android.Event +import at.bitfire.ical4android.impl.TestCalendar +import at.bitfire.ical4android.util.MiscUtils.closeCompat +import at.bitfire.synctools.icalendar.Css3Color +import net.fortuna.ical4j.model.property.DtEnd +import net.fortuna.ical4j.model.property.DtStart +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class AndroidCalendarProviderTest { + + @get:Rule + val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR) + + val testAccount = Account(javaClass.name, CalendarContract.ACCOUNT_TYPE_LOCAL) + + lateinit var client: ContentProviderClient + lateinit var provider: AndroidCalendarProvider + + @Before + fun setUp() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + client = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!! + provider = AndroidCalendarProvider(testAccount, client) + } + + @After + fun tearDown() { + client.closeCompat() + } + + + @Test + fun testProvideCss3Colors() { + provider.provideCss3ColorIndices() + assertEquals(Css3Color.entries.size, countColors()) + } + + @Test + fun testInsertColors_AlreadyThere() { + provider.provideCss3ColorIndices() + provider.provideCss3ColorIndices() + assertEquals(Css3Color.entries.size, countColors()) + } + + @Test + fun testRemoveCss3Colors() { + provider.provideCss3ColorIndices() + + // insert an event with that color + val calendar = TestCalendar.findOrCreate(testAccount, client) + try { + // add event with color + calendar.createEventFromDataObject( + event = Event().apply { + dtStart = DtStart("20210314T204200Z") + dtEnd = DtEnd("20210314T204230Z") + color = Css3Color.limegreen + summary = "Test event with color" + }, + syncId = "remove-colors" + ) + + provider.removeColorIndices() + assertEquals(0, countColors()) + } finally { + calendar.delete() + } + } + + private fun countColors(): Int { + client.query(provider.colorsUri, null, null, null, null)!!.use { cursor -> + cursor.moveToNext() + return cursor.count + } + } + +} \ No newline at end of file diff --git a/lib/src/androidTest/kotlin/at/bitfire/synctools/storage/calendar/AndroidCalendarTest.kt b/lib/src/androidTest/kotlin/at/bitfire/synctools/storage/calendar/AndroidCalendarTest.kt index e2d55b09..69954d07 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/synctools/storage/calendar/AndroidCalendarTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/synctools/storage/calendar/AndroidCalendarTest.kt @@ -9,62 +9,44 @@ package at.bitfire.synctools.storage.calendar import android.Manifest import android.accounts.Account import android.content.ContentProviderClient +import android.os.Build import android.provider.CalendarContract import android.provider.CalendarContract.Calendars import androidx.core.content.contentValuesOf import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule -import at.bitfire.ical4android.AndroidEvent import at.bitfire.ical4android.Event -import at.bitfire.ical4android.impl.TestCalendar import at.bitfire.ical4android.util.MiscUtils.closeCompat -import at.bitfire.synctools.icalendar.Css3Color -import net.fortuna.ical4j.model.property.DtEnd import net.fortuna.ical4j.model.property.DtStart -import org.junit.AfterClass +import net.fortuna.ical4j.model.property.RRule +import net.fortuna.ical4j.model.property.RecurrenceId +import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Before -import org.junit.BeforeClass -import org.junit.ClassRule +import org.junit.Rule import org.junit.Test class AndroidCalendarTest { - companion object { + @get:Rule + val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR) - @JvmField - @ClassRule - val permissionRule = GrantPermissionRule.grant( - Manifest.permission.READ_CALENDAR, - Manifest.permission.WRITE_CALENDAR - ) - - lateinit var client: ContentProviderClient - - @BeforeClass - @JvmStatic - fun connectProvider() { - client = InstrumentationRegistry.getInstrumentation().targetContext.contentResolver.acquireContentProviderClient( - CalendarContract.AUTHORITY)!! - } - - @AfterClass - @JvmStatic - fun closeProvider() { - client.closeCompat() - } - - } - - private val testAccount = Account("ical4android.AndroidCalendarTest", CalendarContract.ACCOUNT_TYPE_LOCAL) + lateinit var client: ContentProviderClient lateinit var provider: AndroidCalendarProvider + private val testAccount = Account(javaClass.name, CalendarContract.ACCOUNT_TYPE_LOCAL) + @Before - fun prepare() { - // make sure there are no colors for testAccount + fun setUp() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + client = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!! provider = AndroidCalendarProvider(testAccount, client) - provider.removeColorIndices() - assertEquals(0, countColors()) + } + + @After + fun tearDown() { + client.closeCompat() } @@ -86,44 +68,119 @@ class AndroidCalendarTest { @Test - fun testProvideCss3Colors() { - provider.provideCss3ColorIndices() - assertEquals(Css3Color.entries.size, countColors()) + fun testNumInstances_SingleInstance() { + val calendar = provider.createAndGetCalendar(contentValuesOf()) + try { + val eventId = calendar.createEventFromDataObject(Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event with 1 instance" + }) + assertEquals(1, calendar.numInstances(eventId)) + } finally { + calendar.delete() + } + } + + @Test + fun testNumInstances_Recurring() { + val calendar = provider.createAndGetCalendar(contentValuesOf()) + try { + val eventId = calendar.createEventFromDataObject(Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event with 5 instances" + rRules.add(RRule("FREQ=DAILY;COUNT=5")) + }) + assertEquals(5, calendar.numInstances(eventId)) + } finally { + calendar.delete() + } } @Test - fun testInsertColors_AlreadyThere() { - provider.provideCss3ColorIndices() - provider.provideCss3ColorIndices() - assertEquals(Css3Color.entries.size, countColors()) + fun testNumInstances_Recurring_Endless() { + val calendar = provider.createAndGetCalendar(contentValuesOf()) + try { + val eventId = calendar.createEventFromDataObject(Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event with infinite instances" + rRules.add(RRule("FREQ=YEARLY")) + }) + assertNull(calendar.numInstances(eventId)) + } finally { + calendar.delete() + } } @Test - fun testRemoveCss3Colors() { - provider.provideCss3ColorIndices() + fun testNumInstances_Recurring_LateEnd() { + val calendar = provider.createAndGetCalendar(contentValuesOf()) + try { + val eventId = calendar.createEventFromDataObject(Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event over 22 years" + rRules.add(RRule("FREQ=YEARLY;UNTIL=20740119T010203Z")) // year 2074 not supported by Android <11 Calendar Storage + }) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) + assertEquals(52, calendar.numInstances(eventId)) + else + assertNull(calendar.numInstances(eventId)) + } finally { + calendar.delete() + } + } - // insert an event with that color - val cal = TestCalendar.findOrCreate(testAccount, client) + @Test + fun testNumInstances_Recurring_ManyInstances() { + val calendar = provider.createAndGetCalendar(contentValuesOf()) try { - // add event with color - AndroidEvent(cal, Event().apply { - dtStart = DtStart("20210314T204200Z") - dtEnd = DtEnd("20210314T204230Z") - color = Css3Color.limegreen - summary = "Test event with color" - }, "remove-colors").add() - - provider.removeColorIndices() - assertEquals(0, countColors()) + val eventId = calendar.createEventFromDataObject(Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event over two years" + rRules.add(RRule("FREQ=DAILY;UNTIL=20240120T010203Z")) + }) + + assertEquals( + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) + 365 * 2 // Android <10: does not include UNTIL (incorrect!) + else + 365 * 2 + 1, // Android ≥10: includes UNTIL (correct) + calendar.numInstances(eventId) + ) } finally { - cal.delete() + calendar.delete() } } - private fun countColors(): Int { - client.query(provider.colorsUri, null, null, null, null)!!.use { cursor -> - cursor.moveToNext() - return cursor.count + @Test + fun testNumInstances_RecurringWithExceptions() { + val calendar = provider.createAndGetCalendar(contentValuesOf()) + try { + val eventId = calendar.createEventFromDataObject( + event = Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event with 6 instances" + rRules.add(RRule("FREQ=DAILY;COUNT=6")) + exceptions.add(Event().apply { + recurrenceId = RecurrenceId("20220122T010203Z") + dtStart = DtStart("20220122T130203Z") + summary = "Exception on 3rd day" + }) + exceptions.add(Event().apply { + recurrenceId = RecurrenceId("20220124T010203Z") + dtStart = DtStart("20220122T160203Z") + summary = "Exception on 5th day" + }) + }, + syncId = "filename.ics" + ) + + // explicitly read from calendar provider + calendar.getEvent(eventId) + + assertEquals(6, calendar.numInstances(eventId)) + } finally { + calendar.delete() } } diff --git a/lib/src/androidTest/kotlin/at/bitfire/ical4android/AndroidEventTest.kt b/lib/src/androidTest/kotlin/at/bitfire/synctools/storage/calendar/AndroidEventTest.kt similarity index 89% rename from lib/src/androidTest/kotlin/at/bitfire/ical4android/AndroidEventTest.kt rename to lib/src/androidTest/kotlin/at/bitfire/synctools/storage/calendar/AndroidEventTest.kt index 95f17016..07064fd7 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/ical4android/AndroidEventTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/synctools/storage/calendar/AndroidEventTest.kt @@ -3,73 +3,9 @@ * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. * SPDX-License-Identifier: GPL-3.0-or-later */ -package at.bitfire.ical4android - -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.os.Build -import android.provider.CalendarContract.ACCOUNT_TYPE_LOCAL -import android.provider.CalendarContract.AUTHORITY -import android.provider.CalendarContract.Attendees -import android.provider.CalendarContract.Calendars -import android.provider.CalendarContract.Events -import android.provider.CalendarContract.ExtendedProperties -import android.provider.CalendarContract.Reminders -import androidx.core.content.contentValuesOf -import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation -import at.bitfire.ical4android.impl.TestCalendar -import at.bitfire.ical4android.util.AndroidTimeUtils -import at.bitfire.ical4android.util.DateUtils -import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter -import at.bitfire.ical4android.util.MiscUtils.closeCompat -import at.bitfire.synctools.icalendar.Css3Color -import at.bitfire.synctools.storage.calendar.AndroidCalendar -import at.bitfire.synctools.storage.calendar.AndroidCalendarProvider -import at.bitfire.synctools.test.InitCalendarProviderRule -import net.fortuna.ical4j.model.Date -import net.fortuna.ical4j.model.DateList -import net.fortuna.ical4j.model.DateTime -import net.fortuna.ical4j.model.Parameter -import net.fortuna.ical4j.model.ParameterList -import net.fortuna.ical4j.model.Property -import net.fortuna.ical4j.model.Recur -import net.fortuna.ical4j.model.component.VAlarm -import net.fortuna.ical4j.model.parameter.Cn -import net.fortuna.ical4j.model.parameter.CuType -import net.fortuna.ical4j.model.parameter.Email -import net.fortuna.ical4j.model.parameter.Language -import net.fortuna.ical4j.model.parameter.PartStat -import net.fortuna.ical4j.model.parameter.Related -import net.fortuna.ical4j.model.parameter.Role -import net.fortuna.ical4j.model.parameter.Rsvp -import net.fortuna.ical4j.model.parameter.Value -import net.fortuna.ical4j.model.property.* -import net.fortuna.ical4j.util.TimeZones -import org.junit.After -import org.junit.AfterClass -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotEquals -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.BeforeClass -import org.junit.ClassRule -import org.junit.Test -import org.junit.rules.TestRule -import java.net.URI -import java.time.Duration -import java.time.Period -import java.util.UUID -import java.util.logging.Logger -import kotlin.collections.plusAssign - -class AndroidEventTest { +package at.bitfire.synctools.storage.calendar + +/*class AndroidEventTest { companion object { @@ -2535,212 +2471,4 @@ class AndroidEventTest { } } - - @Test - fun testNumDirectInstances_SingleInstance() { - val event = Event().apply { - dtStart = DtStart("20220120T010203Z") - summary = "Event with 1 instance" - } - val localEvent = AndroidEvent(calendar, event, null, null, null, 0) - localEvent.add() - - assertEquals(1, AndroidEvent.numDirectInstances(client, testAccount, localEvent.id!!)) - } - - @Test - fun testNumDirectInstances_Recurring() { - val event = Event().apply { - dtStart = DtStart("20220120T010203Z") - summary = "Event with 5 instances" - rRules.add(RRule("FREQ=DAILY;COUNT=5")) - } - val localEvent = AndroidEvent(calendar, event, null, null, null, 0) - localEvent.add() - - assertEquals(5, AndroidEvent.numDirectInstances(client, testAccount, localEvent.id!!)) - } - - @Test - fun testNumDirectInstances_Recurring_Endless() { - val event = Event().apply { - dtStart = DtStart("20220120T010203Z") - summary = "Event without end" - rRules.add(RRule("FREQ=DAILY")) - } - val localEvent = AndroidEvent(calendar, event, null, null, null, 0) - localEvent.add() - - assertNull(AndroidEvent.numDirectInstances(client, testAccount, localEvent.id!!)) - } - - @Test - // flaky, needs InitCalendarProviderRule - fun testNumDirectInstances_Recurring_LateEnd() { - val event = Event().apply { - dtStart = DtStart("20220120T010203Z") - summary = "Event with 53 years" - rRules.add(RRule("FREQ=YEARLY;UNTIL=20740119T010203Z")) // year 2074 is not supported by Android <11 Calendar Storage - } - val localEvent = AndroidEvent(calendar, event, null, null, null, 0) - localEvent.add() - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) - assertEquals(52, AndroidEvent.numDirectInstances(client, testAccount, localEvent.id!!)) - else - assertNull(AndroidEvent.numDirectInstances(client, testAccount, localEvent.id!!)) - } - - @Test - fun testNumDirectInstances_Recurring_ManyInstances() { - val event = Event().apply { - dtStart = DtStart("20220120T010203Z") - summary = "Event with 2 years" - rRules.add(RRule("FREQ=DAILY;UNTIL=20240120T010203Z")) - } - val localEvent = AndroidEvent(calendar, event, null, null, null, 0) - localEvent.add() - val number = AndroidEvent.numDirectInstances(client, testAccount, localEvent.id!!) - - // Some android versions (i.e. <=Q and S) return 365*2 instances (wrong, 365*2+1 => correct), - // but we are satisfied with either result for now - assertTrue(number == 365 * 2 || number == 365 * 2 + 1) - } - - @Test - fun testNumDirectInstances_RecurringWithExdate() { - val event = Event().apply { - dtStart = DtStart(Date("20220120T010203Z")) - summary = "Event with 5 instances" - rRules.add(RRule("FREQ=DAILY;COUNT=5")) - exDates.add(ExDate(DateList("20220121T010203Z", Value.DATE_TIME))) - } - val localEvent = AndroidEvent(calendar, event, null, null, null, 0) - localEvent.add() - - assertEquals(4, AndroidEvent.numDirectInstances(client, testAccount, localEvent.id!!)) - } - - @Test - fun testNumDirectInstances_RecurringWithExceptions() { - val event = Event().apply { - dtStart = DtStart("20220120T010203Z") - summary = "Event with 5 instances" - rRules.add(RRule("FREQ=DAILY;COUNT=5")) - exceptions.add(Event().apply { - recurrenceId = RecurrenceId("20220122T010203Z") - dtStart = DtStart("20220122T130203Z") - summary = "Exception on 3rd day" - }) - exceptions.add(Event().apply { - recurrenceId = RecurrenceId("20220124T010203Z") - dtStart = DtStart("20220122T160203Z") - summary = "Exception on 5th day" - }) - } - val localEvent = AndroidEvent(calendar, event, "filename.ics", null, null, 0) - localEvent.add() - - assertEquals(5 - 2, AndroidEvent.numDirectInstances(client, testAccount, localEvent.id!!)) - } - - - @Test - fun testNumInstances_SingleInstance() { - val event = Event().apply { - dtStart = DtStart("20220120T010203Z") - summary = "Event with 1 instance" - } - val localEvent = AndroidEvent(calendar, event, null, null, null, 0) - localEvent.add() - - assertEquals(1, AndroidEvent.numInstances(client, testAccount, localEvent.id!!)) - } - - @Test - fun testNumInstances_Recurring() { - val event = Event().apply { - dtStart = DtStart("20220120T010203Z") - summary = "Event with 5 instances" - rRules.add(RRule("FREQ=DAILY;COUNT=5")) - } - val localEvent = AndroidEvent(calendar, event, null, null, null, 0) - localEvent.add() - - assertEquals(5, AndroidEvent.numInstances(client, testAccount, localEvent.id!!)) - } - - @Test - fun testNumInstances_Recurring_Endless() { - val event = Event().apply { - dtStart = DtStart("20220120T010203Z") - summary = "Event with infinite instances" - rRules.add(RRule("FREQ=YEARLY")) - } - val localEvent = AndroidEvent(calendar, event, null, null, null, 0) - localEvent.add() - - assertNull(AndroidEvent.numInstances(client, testAccount, localEvent.id!!)) - } - - @Test - fun testNumInstances_Recurring_LateEnd() { - val event = Event().apply { - dtStart = DtStart("20220120T010203Z") - summary = "Event over 22 years" - rRules.add(RRule("FREQ=YEARLY;UNTIL=20740119T010203Z")) // year 2074 not supported by Android <11 Calendar Storage - } - val localEvent = AndroidEvent(calendar, event, null, null, null, 0) - localEvent.add() - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) - assertEquals(52, AndroidEvent.numInstances(client, testAccount, localEvent.id!!)) - else - assertNull(AndroidEvent.numInstances(client, testAccount, localEvent.id!!)) - } - - @Test - fun testNumInstances_Recurring_ManyInstances() { - val event = Event().apply { - dtStart = DtStart("20220120T010203Z") - summary = "Event over two years" - rRules.add(RRule("FREQ=DAILY;UNTIL=20240120T010203Z")) - } - val localEvent = AndroidEvent(calendar, event, null, null, null, 0) - localEvent.add() - - assertEquals( - if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) - 365 * 2 // Android <10: does not include UNTIL (incorrect!) - else - 365 * 2 + 1, // Android ≥10: includes UNTIL (correct) - AndroidEvent.numInstances(client, testAccount, localEvent.id!!) - ) - } - - @Test - fun testNumInstances_RecurringWithExceptions() { - val event = Event().apply { - dtStart = DtStart("20220120T010203Z") - summary = "Event with 6 instances" - rRules.add(RRule("FREQ=DAILY;COUNT=6")) - exceptions.add(Event().apply { - recurrenceId = RecurrenceId("20220122T010203Z") - dtStart = DtStart("20220122T130203Z") - summary = "Exception on 3rd day" - }) - exceptions.add(Event().apply { - recurrenceId = RecurrenceId("20220124T010203Z") - dtStart = DtStart("20220122T160203Z") - summary = "Exception on 5th day" - }) - } - val localEvent = AndroidEvent(calendar, event, "filename.ics", null, null, 0) - localEvent.add() - - calendar.getEvent(localEvent.id!!)!! - - assertEquals(6, AndroidEvent.numInstances(client, testAccount, localEvent.id!!)) - } - -} \ No newline at end of file +}*/ \ No newline at end of file diff --git a/lib/src/androidTest/kotlin/at/bitfire/ical4android/UnknownPropertyTest.kt b/lib/src/androidTest/kotlin/at/bitfire/synctools/storage/calendar/UnknownPropertyTest.kt similarity index 61% rename from lib/src/androidTest/kotlin/at/bitfire/ical4android/UnknownPropertyTest.kt rename to lib/src/androidTest/kotlin/at/bitfire/synctools/storage/calendar/UnknownPropertyTest.kt index 937f8b9d..f270abaa 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/ical4android/UnknownPropertyTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/synctools/storage/calendar/UnknownPropertyTest.kt @@ -4,7 +4,7 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -package at.bitfire.ical4android +package at.bitfire.synctools.storage.calendar import androidx.test.filters.SmallTest import net.fortuna.ical4j.model.Parameter @@ -13,8 +13,7 @@ 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.Assert import org.junit.Test class UnknownPropertyTest { @@ -23,21 +22,21 @@ class UnknownPropertyTest { @SmallTest fun testFromJsonString() { val prop = UnknownProperty.fromJsonString("[ \"UID\", \"PropValue\" ]") - assertTrue(prop is Uid) - assertEquals("UID", prop.name) - assertEquals("PropValue", prop.value) + Assert.assertTrue(prop is Uid) + Assert.assertEquals("UID", prop.name) + Assert.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) + Assert.assertTrue(prop is Attendee) + Assert.assertEquals("ATTENDEE", prop.name) + Assert.assertEquals("PropValue", prop.value) + Assert.assertEquals(2, prop.parameters.size()) + Assert.assertEquals("value1", prop.parameters.getParameter("x-param1").value) + Assert.assertEquals("value2", prop.parameters.getParameter("x-param2").value) } @Test(expected = JSONException::class) @@ -51,16 +50,16 @@ class UnknownPropertyTest { @SmallTest fun testToJsonString() { val attendee = Attendee("mailto:test@test.at") - assertEquals( - "ATTENDEE:mailto:test@test.at", - attendee.toString().trim() + Assert.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() + Assert.assertEquals( + "ATTENDEE;RSVP=TRUE;X-My-Param=SomeValue:mailto:test@test.at", + attendee.toString().trim() ) } diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/AndroidEvent.kt b/lib/src/main/kotlin/at/bitfire/ical4android/AndroidEvent.kt deleted file mode 100644 index 822a80bd..00000000 --- a/lib/src/main/kotlin/at/bitfire/ical4android/AndroidEvent.kt +++ /dev/null @@ -1,1302 +0,0 @@ -/* - * This file is part of bitfireAT/synctools which is released under GPLv3. - * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package at.bitfire.ical4android - -import android.accounts.Account -import android.content.ContentProviderClient -import android.content.ContentResolver -import android.content.ContentUris -import android.content.ContentValues -import android.content.EntityIterator -import android.net.Uri -import android.os.RemoteException -import android.provider.CalendarContract -import android.provider.CalendarContract.Attendees -import android.provider.CalendarContract.Colors -import android.provider.CalendarContract.Events -import android.provider.CalendarContract.EventsEntity -import android.provider.CalendarContract.ExtendedProperties -import android.provider.CalendarContract.Reminders -import android.util.Patterns -import androidx.core.content.contentValuesOf -import at.bitfire.ical4android.AndroidEvent.Companion.CATEGORIES_SEPARATOR -import at.bitfire.ical4android.AndroidEvent.Companion.numInstances -import at.bitfire.ical4android.util.AndroidTimeUtils -import at.bitfire.ical4android.util.DateUtils -import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter -import at.bitfire.ical4android.util.TimeApiExtensions -import at.bitfire.ical4android.util.TimeApiExtensions.requireZoneId -import at.bitfire.ical4android.util.TimeApiExtensions.toIcal4jDate -import at.bitfire.ical4android.util.TimeApiExtensions.toIcal4jDateTime -import at.bitfire.ical4android.util.TimeApiExtensions.toLocalDate -import at.bitfire.ical4android.util.TimeApiExtensions.toLocalTime -import at.bitfire.ical4android.util.TimeApiExtensions.toRfc5545Duration -import at.bitfire.ical4android.util.TimeApiExtensions.toZonedDateTime -import at.bitfire.synctools.exception.InvalidLocalResourceException -import at.bitfire.synctools.icalendar.Css3Color -import at.bitfire.synctools.storage.BatchOperation.CpoBuilder -import at.bitfire.synctools.storage.LocalStorageException -import at.bitfire.synctools.storage.calendar.AndroidCalendar -import at.bitfire.synctools.storage.calendar.CalendarBatchOperation -import at.bitfire.synctools.storage.removeBlank -import at.bitfire.synctools.storage.toContentValues -import net.fortuna.ical4j.model.Date -import net.fortuna.ical4j.model.DateList -import net.fortuna.ical4j.model.DateTime -import net.fortuna.ical4j.model.Parameter -import net.fortuna.ical4j.model.Property -import net.fortuna.ical4j.model.component.VAlarm -import net.fortuna.ical4j.model.parameter.Cn -import net.fortuna.ical4j.model.parameter.Email -import net.fortuna.ical4j.model.parameter.PartStat -import net.fortuna.ical4j.model.parameter.Rsvp -import net.fortuna.ical4j.model.parameter.Value -import net.fortuna.ical4j.model.property.Action -import net.fortuna.ical4j.model.property.Attendee -import net.fortuna.ical4j.model.property.Clazz -import net.fortuna.ical4j.model.property.Description -import net.fortuna.ical4j.model.property.DtEnd -import net.fortuna.ical4j.model.property.DtStart -import net.fortuna.ical4j.model.property.ExDate -import net.fortuna.ical4j.model.property.ExRule -import net.fortuna.ical4j.model.property.Organizer -import net.fortuna.ical4j.model.property.RDate -import net.fortuna.ical4j.model.property.RRule -import net.fortuna.ical4j.model.property.RecurrenceId -import net.fortuna.ical4j.model.property.Status -import net.fortuna.ical4j.model.property.Summary -import net.fortuna.ical4j.util.TimeZones -import java.io.FileNotFoundException -import java.net.URI -import java.net.URISyntaxException -import java.time.Duration -import java.time.Instant -import java.time.Period -import java.time.ZoneOffset -import java.time.ZonedDateTime -import java.util.Locale -import java.util.logging.Level -import java.util.logging.Logger - -/** - * Stores and retrieves VEVENT iCalendar objects (represented as [Event]s) to/from the - * Android Calendar provider. - * - * Extend this class to process specific fields of the event. - * - * Important: To use recurrence exceptions, you MUST set _SYNC_ID and ORIGINAL_SYNC_ID - * in populateEvent() / buildEvent. Setting _ID and ORIGINAL_ID is not sufficient. - */ -class AndroidEvent( - val calendar: AndroidCalendar -) { - - private val logger: Logger - get() = Logger.getLogger(javaClass.name) - - var id: Long? = null - private set - - var syncId: String? = null - - var eTag: String? = null - var scheduleTag: String? = null - var flags: Int = 0 - - /** - * Creates a new object from an event which already exists in the calendar storage. - * - * @param values database row with all columns, as returned by the calendar provider - */ - constructor(calendar: AndroidCalendar, values: ContentValues) : this(calendar) { - this.id = values.getAsLong(Events._ID) - this.syncId = values.getAsString(Events._SYNC_ID) - this.eTag = values.getAsString(COLUMN_ETAG) - this.scheduleTag = values.getAsString(COLUMN_SCHEDULE_TAG) - this.flags = values.getAsInteger(COLUMN_FLAGS) ?: 0 - } - - /** - * Creates a new object from an event which doesn't exist in the calendar storage yet. - * - * @param event event that can be saved into the calendar storage - */ - constructor( - calendar: AndroidCalendar, - event: Event, - syncId: String?, - eTag: String? = null, - scheduleTag: String? = null, - flags: Int = 0 - ) : this(calendar) { - this.event = event - this.syncId = syncId - this.eTag = eTag - this.scheduleTag = scheduleTag - this.flags = flags - } - - private var _event: Event? = null - - /** - * Returns the full event data, either from [event] or, if [event] is null, by reading event - * number [id] from the Android calendar storage - * @throws IllegalArgumentException if event has not been saved yet - * @throws FileNotFoundException if there's no event with [id] in the calendar storage - * @throws RemoteException on calendar provider errors - */ - var event: Event? - private set(value) { - _event = value - } - get() { - if (_event != null) - return _event - val id = requireNotNull(id) - - var iterEvents: EntityIterator? = null - try { - iterEvents = EventsEntity.newEntityIterator( - calendar.client.query( - ContentUris.withAppendedId(EventsEntity.CONTENT_URI, id).asSyncAdapter(calendar.account), - null, null, null, null), - calendar.client - ) - - if (iterEvents.hasNext()) { - val e = iterEvents.next() - - // create new Event which will be populated - val newEvent = Event() - _event = newEvent - - // calculate some scheduling properties - val groupScheduled = e.subValues.any { it.uri == Attendees.CONTENT_URI } - - populateEvent(e.entityValues.removeBlank(), groupScheduled) - - for (subValue in e.subValues) { - val subValues = subValue.values.removeBlank() - when (subValue.uri) { - Attendees.CONTENT_URI -> populateAttendee(subValues) - Reminders.CONTENT_URI -> populateReminder(subValues) - ExtendedProperties.CONTENT_URI -> populateExtended(subValues) - } - } - populateExceptions() - useRetainedClassification() - return newEvent - } - } catch (e: Exception) { - /* Populating event has been interrupted by an exception, so we reset the event to - avoid an inconsistent state. This also ensures that the exception will be thrown - again on the next get() call. */ - _event = null - throw e - } finally { - iterEvents?.close() - } - throw FileNotFoundException("Couldn't find event $id") - } - - /** - * Reads event data from the calendar provider. - * - * @param row values of an [Events] row, as returned by the calendar provider - */ - private fun populateEvent(row: ContentValues, groupScheduled: Boolean) { - logger.log(Level.FINE, "Read event entity from calender provider", row) - val event = requireNotNull(event) - - row.getAsString(Events.MUTATORS)?.let { strPackages -> - val packages = strPackages.split(MUTATORS_SEPARATOR).toSet() - event.userAgents.addAll(packages) - } - - val allDay = (row.getAsInteger(Events.ALL_DAY) ?: 0) != 0 - val tsStart = row.getAsLong(Events.DTSTART) ?: throw InvalidLocalResourceException("Found event without DTSTART") - - var tsEnd = row.getAsLong(Events.DTEND) - var duration = // only use DURATION of DTEND is not defined - if (tsEnd == null) - row.getAsString(Events.DURATION)?.let { AndroidTimeUtils.parseDuration(it) } - else - null - - if (allDay) { - event.dtStart = DtStart(Date(tsStart)) - - // Android events MUST have duration or dtend [https://developer.android.com/reference/android/provider/CalendarContract.Events#operations]. - // Assume 1 day if missing (should never occur, but occurs). - if (tsEnd == null && duration == null) - duration = Duration.ofDays(1) - - if (duration != null) { - // Some servers have problems with DURATION, so we always generate DTEND. - val startDate = ZonedDateTime.ofInstant(Instant.ofEpochMilli(tsStart), ZoneOffset.UTC).toLocalDate() - if (duration is Duration) - duration = Period.ofDays(duration.toDays().toInt()) - tsEnd = (startDate + duration).toEpochDay() * TimeApiExtensions.MILLIS_PER_DAY - duration = null - } - - if (tsEnd != null) { - when { - tsEnd < tsStart -> - logger.warning("dtEnd $tsEnd (allDay) < dtStart $tsStart (allDay), ignoring") - - tsEnd == tsStart -> - logger.fine("dtEnd $tsEnd (allDay) = dtStart, won't generate DTEND property") - - else /* tsEnd > tsStart */ -> - event.dtEnd = DtEnd(Date(tsEnd)) - } - } - - } else /* !allDay */ { - // use DATE-TIME values - - // check time zone ID (calendar apps may insert no or an invalid ID) - val startTzId = DateUtils.findAndroidTimezoneID(row.getAsString(Events.EVENT_TIMEZONE)) - val startTz = DateUtils.ical4jTimeZone(startTzId) - val dtStartDateTime = DateTime(tsStart).apply { - if (startTz != null) { // null if there was not ical4j time zone for startTzId, which should not happen, but technically may happen - if (TimeZones.isUtc(startTz)) - isUtc = true - else - timeZone = startTz - } - } - event.dtStart = DtStart(dtStartDateTime) - - // Android events MUST have duration or dtend [https://developer.android.com/reference/android/provider/CalendarContract.Events#operations]. - // Assume 1 hour if missing (should never occur, but occurs). - if (tsEnd == null && duration == null) - duration = Duration.ofHours(1) - - if (duration != null) { - // Some servers have problems with DURATION, so we always generate DTEND. - val zonedStart = dtStartDateTime.toZonedDateTime() - tsEnd = (zonedStart + duration).toInstant().toEpochMilli() - duration = null - } - - if (tsEnd != null) { - if (tsEnd < tsStart) - logger.warning("dtEnd $tsEnd < dtStart $tsStart, ignoring") - /*else if (tsEnd == tsStart) // iCloud sends 404 when it receives an iCalendar with DTSTART but without DTEND - logger.fine("dtEnd $tsEnd == dtStart, won't generate DTEND property")*/ - else /* tsEnd > tsStart */ { - val endTz = row.getAsString(Events.EVENT_END_TIMEZONE)?.let { tzId -> - DateUtils.ical4jTimeZone(tzId) - } ?: startTz - event.dtEnd = DtEnd(DateTime(tsEnd).apply { - if (endTz != null) { - if (TimeZones.isUtc(endTz)) - isUtc = true - else - timeZone = endTz - } - }) - } - } - - } - - // recurrence - try { - row.getAsString(Events.RRULE)?.let { rulesStr -> - for (rule in rulesStr.split(AndroidTimeUtils.RECURRENCE_RULE_SEPARATOR)) - event.rRules += RRule(rule) - } - row.getAsString(Events.RDATE)?.let { datesStr -> - val rDate = AndroidTimeUtils.androidStringToRecurrenceSet(datesStr, allDay, tsStart) { RDate(it) } - event.rDates += rDate - } - - row.getAsString(Events.EXRULE)?.let { rulesStr -> - for (rule in rulesStr.split(AndroidTimeUtils.RECURRENCE_RULE_SEPARATOR)) - event.exRules += ExRule(null, rule) - } - row.getAsString(Events.EXDATE)?.let { datesStr -> - val exDate = AndroidTimeUtils.androidStringToRecurrenceSet(datesStr, allDay) { ExDate(it) } - event.exDates += exDate - } - } catch (e: Exception) { - logger.log(Level.WARNING, "Couldn't parse recurrence rules, ignoring", e) - } - - event.uid = row.getAsString(Events.UID_2445) - event.sequence = row.getAsInteger(COLUMN_SEQUENCE) - event.isOrganizer = row.getAsBoolean(Events.IS_ORGANIZER) - - event.summary = row.getAsString(Events.TITLE) - event.location = row.getAsString(Events.EVENT_LOCATION) - event.description = row.getAsString(Events.DESCRIPTION) - - // color can be specified as RGB value and/or as index key (CSS3 color of AndroidCalendar) - event.color = - row.getAsString(Events.EVENT_COLOR_KEY)?.let { name -> // try color key first - try { - Css3Color.valueOf(name) - } catch (_: IllegalArgumentException) { - logger.warning("Ignoring unknown color name \"$name\"") - null - } - } ?: - row.getAsInteger(Events.EVENT_COLOR)?.let { color -> // otherwise, try to find the color name from the value - Css3Color.entries.firstOrNull { it.argb == color } - } - - // status - when (row.getAsInteger(Events.STATUS)) { - Events.STATUS_CONFIRMED -> event.status = Status.VEVENT_CONFIRMED - Events.STATUS_TENTATIVE -> event.status = Status.VEVENT_TENTATIVE - Events.STATUS_CANCELED -> event.status = Status.VEVENT_CANCELLED - } - - // availability - event.opaque = row.getAsInteger(Events.AVAILABILITY) != Events.AVAILABILITY_FREE - - // scheduling - if (groupScheduled) { - // ORGANIZER must only be set for group-scheduled events (= events with attendees) - if (row.containsKey(Events.ORGANIZER)) - try { - event.organizer = Organizer(URI("mailto", row.getAsString(Events.ORGANIZER), null)) - } catch (e: URISyntaxException) { - logger.log(Level.WARNING, "Error when creating ORGANIZER mailto URI, ignoring", e) - } - } - - // classification - when (row.getAsInteger(Events.ACCESS_LEVEL)) { - Events.ACCESS_PUBLIC -> event.classification = Clazz.PUBLIC - Events.ACCESS_PRIVATE -> event.classification = Clazz.PRIVATE - Events.ACCESS_CONFIDENTIAL -> event.classification = Clazz.CONFIDENTIAL - } - - // exceptions from recurring events - row.getAsLong(Events.ORIGINAL_INSTANCE_TIME)?.let { originalInstanceTime -> - val originalAllDay = (row.getAsInteger(Events.ORIGINAL_ALL_DAY) ?: 0) != 0 - val originalDate = - if (originalAllDay) - Date(originalInstanceTime) - else - DateTime(originalInstanceTime) - if (originalDate is DateTime) { - event.dtStart?.let { dtStart -> - if (dtStart.isUtc) - originalDate.isUtc = true - else if (dtStart.timeZone != null) - originalDate.timeZone = dtStart.timeZone - } - } - event.recurrenceId = RecurrenceId(originalDate) - } - } - - private fun populateAttendee(row: ContentValues) { - logger.log(Level.FINE, "Read event attendee from calender provider", row) - - try { - val attendee: Attendee - val email = row.getAsString(Attendees.ATTENDEE_EMAIL) - val idNS = row.getAsString(Attendees.ATTENDEE_ID_NAMESPACE) - val id = row.getAsString(Attendees.ATTENDEE_IDENTITY) - - if (idNS != null || id != null) { - // attendee identified by namespace and ID - attendee = Attendee(URI(idNS, id, null)) - email?.let { attendee.parameters.add(Email(it)) } - } else - // attendee identified by email address - attendee = Attendee(URI("mailto", email, null)) - val params = attendee.parameters - - // always add RSVP (offer attendees to accept/decline) - params.add(Rsvp.TRUE) - - row.getAsString(Attendees.ATTENDEE_NAME)?.let { cn -> params.add(Cn(cn)) } - - // type/relation mapping is complex and thus outsourced to AttendeeMappings - AttendeeMappings.androidToICalendar(row, attendee) - - // status - when (row.getAsInteger(Attendees.ATTENDEE_STATUS)) { - Attendees.ATTENDEE_STATUS_INVITED -> params.add(PartStat.NEEDS_ACTION) - Attendees.ATTENDEE_STATUS_ACCEPTED -> params.add(PartStat.ACCEPTED) - Attendees.ATTENDEE_STATUS_DECLINED -> params.add(PartStat.DECLINED) - Attendees.ATTENDEE_STATUS_TENTATIVE -> params.add(PartStat.TENTATIVE) - Attendees.ATTENDEE_STATUS_NONE -> { /* no information, don't add PARTSTAT */ } - } - - event!!.attendees.add(attendee) - } catch (e: URISyntaxException) { - logger.log(Level.WARNING, "Couldn't parse attendee information, ignoring", e) - } - } - - private fun populateReminder(row: ContentValues) { - logger.log(Level.FINE, "Read event reminder from calender provider", row) - val event = requireNotNull(event) - - val alarm = VAlarm(Duration.ofMinutes(-row.getAsLong(Reminders.MINUTES))) - - val props = alarm.properties - 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 { - logger.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) - } - } - event.alarms += alarm - } - - private fun populateExtended(row: ContentValues) { - val name = row.getAsString(ExtendedProperties.NAME) - val rawValue = row.getAsString(ExtendedProperties.VALUE) - logger.log(Level.FINE, "Read extended property from calender provider", arrayOf(name, rawValue)) - val event = requireNotNull(event) - - try { - when (name) { - EXTNAME_CATEGORIES -> - event.categories += rawValue.split(CATEGORIES_SEPARATOR) - - EXTNAME_URL -> - try { - event.url = URI(rawValue) - } catch(_: URISyntaxException) { - logger.warning("Won't process invalid local URL: $rawValue") - } - - EXTNAME_ICAL_UID -> - // only consider iCalUid when there's no uid - if (event.uid == null) - event.uid = rawValue - - UnknownProperty.CONTENT_ITEM_TYPE -> - event.unknownProperties += UnknownProperty.fromJsonString(rawValue) - } - } catch (e: Exception) { - logger.log(Level.WARNING, "Couldn't parse extended property", e) - } - } - - private fun populateExceptions() { - requireNotNull(id) - val event = requireNotNull(event) - - calendar.client.query(Events.CONTENT_URI.asSyncAdapter(calendar.account), - null, - Events.ORIGINAL_ID + "=?", arrayOf(id.toString()), null)?.use { c -> - while (c.moveToNext()) { - val values = c.toContentValues() - try { - val exception = AndroidEvent(calendar, values) - val exceptionEvent = exception.event!! - val recurrenceId = exceptionEvent.recurrenceId!! - - // generate EXDATE instead of RECURRENCE-ID exceptions for cancelled instances - if (exceptionEvent.status == Status.VEVENT_CANCELLED) { - val list = DateList( - if (DateUtils.isDate(recurrenceId)) Value.DATE else Value.DATE_TIME, - recurrenceId.timeZone - ) - list.add(recurrenceId.date) - event.exDates += ExDate(list).apply { - if (DateUtils.isDateTime(recurrenceId)) { - if (recurrenceId.isUtc) - setUtc(true) - else - timeZone = recurrenceId.timeZone - } - } - - } else /* exceptionEvent.status != Status.VEVENT_CANCELLED */ { - // make sure that all components have the same ORGANIZER [RFC 6638 3.1] - exceptionEvent.organizer = event.organizer - - // add exception to list of exceptions - event.exceptions += exceptionEvent - } - } catch (e: Exception) { - logger.log(Level.WARNING, "Couldn't find exception details", e) - } - } - } - } - - private fun retainClassification() { - /* retain classification other than PUBLIC and PRIVATE as unknown property so - that it can be reused when "server default" is selected */ - val event = requireNotNull(event) - event.classification?.let { - if (it != Clazz.PUBLIC && it != Clazz.PRIVATE) - event.unknownProperties += it - } - } - - - /** - * Saves an unsaved event into the calendar storage. - * - * @return content URI of the created event - * - * @throws LocalStorageException when the calendar provider doesn't return a result row - * @throws RemoteException on calendar provider errors - */ - fun add(): Uri { - val batch = CalendarBatchOperation(calendar.client) - val idxEvent = addOrUpdateRows(batch) ?: throw AssertionError("Expected Events._ID backref") - batch.commit() - - val resultUri = batch.getResult(idxEvent)?.uri - ?: throw LocalStorageException("Empty result from content provider when adding event") - id = ContentUris.parseId(resultUri) - return resultUri - } - - /** - * Adds or updates the calendar provider [Events] main row for this [event]. - * - * @param batch batch operation for insert/update operation - * - * @return [Events._ID] of the created/updated row; *null* if now ID is available - */ - fun addOrUpdateRows(batch: CalendarBatchOperation): Int? { - val event = requireNotNull(event) - val builder = - if (id == null) - CpoBuilder.newInsert(Events.CONTENT_URI.asSyncAdapter(calendar.account)) - else - CpoBuilder.newUpdate(eventSyncURI()) - - val idxEvent = if (id == null) batch.nextBackrefIdx() else null - buildEvent(null, builder) - batch += builder - - // add reminders - event.alarms.forEach { insertReminder(batch, idxEvent, it) } - - // add attendees - val organizer = event.organizerEmail ?: - /* no ORGANIZER, use current account owner as ORGANIZER */ - calendar.ownerAccount ?: calendar.account.name - event.attendees.forEach { insertAttendee(batch, idxEvent, it, organizer) } - - // add extended properties - // CATEGORIES - if (event.categories.isNotEmpty()) - insertCategories(batch, idxEvent) - // CLASS - retainClassification() - // URL - event.url?.let { url -> - insertExtendedProperty(batch, idxEvent, EXTNAME_URL, url.toString()) - } - // unknown properties - event.unknownProperties.forEach { - insertUnknownProperty(batch, idxEvent, it) - } - - // add exceptions - for (exception in event.exceptions) { - /* I guess exceptions should be inserted using Events.CONTENT_EXCEPTION_URI so that we could - benefit from some provider logic (for recurring exceptions e.g.). However, this method - has some caveats: - - For instance, only Events.SYNC_DATA1, SYNC_DATA3 and SYNC_DATA7 can be used - in exception events (that's hardcoded in the CalendarProvider, don't ask me why). - - Also, CONTENT_EXCEPTIONS_URI doesn't deal with exceptions for recurring events defined by RDATE - (it checks for RRULE and aborts if no RRULE is found). - So I have chosen the method of inserting the exception event manually. - - It's also noteworthy that linking the main event to the exception only works using _SYNC_ID - and ORIGINAL_SYNC_ID (and not ID and ORIGINAL_ID, as one could assume). So, if you don't - set _SYNC_ID in the main event and ORIGINAL_SYNC_ID in the exception, the exception will - appear additionally (and not *instead* of the instance). - */ - - val recurrenceId = exception.recurrenceId - if (recurrenceId == null) { - logger.warning("Ignoring exception of event ${event.uid} without recurrenceId") - continue - } - - val exBuilder = CpoBuilder - .newInsert(Events.CONTENT_URI.asSyncAdapter(calendar.account)) - .withEventId(Events.ORIGINAL_ID, idxEvent) - - buildEvent(exception, exBuilder) - if (exBuilder.values[Events.ORIGINAL_SYNC_ID] == null && exBuilder.valueBackrefs[Events.ORIGINAL_SYNC_ID] == null) - throw AssertionError("buildEvent(exception) must set ORIGINAL_SYNC_ID") - - var recurrenceDate = recurrenceId.date - val dtStartDate = event.dtStart!!.date - if (recurrenceDate is DateTime && dtStartDate !is DateTime) { - // rewrite RECURRENCE-ID;VALUE=DATE-TIME to VALUE=DATE for all-day events - val localDate = recurrenceDate.toLocalDate() - recurrenceDate = Date(localDate.toIcal4jDate()) - - } else if (recurrenceDate !is DateTime && dtStartDate is DateTime) { - // rewrite RECURRENCE-ID;VALUE=DATE to VALUE=DATE-TIME for non-all-day-events - val localDate = recurrenceDate.toLocalDate() - // guess time and time zone from DTSTART - val zonedTime = ZonedDateTime.of( - localDate, - dtStartDate.toLocalTime(), - dtStartDate.requireZoneId() - ) - recurrenceDate = zonedTime.toIcal4jDateTime() - } - exBuilder .withValue(Events.ORIGINAL_ALL_DAY, if (DateUtils.isDate(event.dtStart)) 1 else 0) - .withValue(Events.ORIGINAL_INSTANCE_TIME, recurrenceDate.time) - - val idxException = batch.nextBackrefIdx() - batch += exBuilder - - // add exception reminders - exception.alarms.forEach { insertReminder(batch, idxException, it) } - - // add exception attendees - exception.attendees.forEach { insertAttendee(batch, idxException, it, organizer) } - } - - return idxEvent - } - - /** - * Updates an already existing event in the calendar storage with the values - * from the instance. - * @throws LocalStorageException when the calendar provider doesn't return a result row - * @throws RemoteException on calendar provider errors - */ - fun update(event: Event): Uri { - this.event = event - val existingId = requireNotNull(id) - - // There are cases where the event cannot be updated, but must be completely re-created. - // Case 1: Events.STATUS shall be updated from a non-null value (like STATUS_CONFIRMED) to null. - var rebuild = false - if (event.status == null) - calendar.client.query(eventSyncURI(), arrayOf(Events.STATUS), null, null, null)?.use { cursor -> - if (cursor.moveToNext()) { - val statusIndex = cursor.getColumnIndexOrThrow(Events.STATUS) - if (!cursor.isNull(statusIndex)) - rebuild = true - } - } - - if (rebuild) { // delete whole event and insert updated event - delete() - return add() - - } else { // update event - // remove associated rows which are added later again - val batch = CalendarBatchOperation(calendar.client) - deleteExceptions(batch) - batch += CpoBuilder - .newDelete(Reminders.CONTENT_URI.asSyncAdapter(calendar.account)) - .withSelection("${Reminders.EVENT_ID}=?", arrayOf(existingId.toString())) - batch += CpoBuilder - .newDelete(Attendees.CONTENT_URI.asSyncAdapter(calendar.account)) - .withSelection("${Attendees.EVENT_ID}=?", arrayOf(existingId.toString())) - batch += CpoBuilder - .newDelete(ExtendedProperties.CONTENT_URI.asSyncAdapter(calendar.account)) - .withSelection( - "${ExtendedProperties.EVENT_ID}=? AND ${ExtendedProperties.NAME} IN (?,?,?,?)", - arrayOf( - existingId.toString(), - EXTNAME_CATEGORIES, - EXTNAME_ICAL_UID, // UID is stored in UID_2445, don't leave iCalUid rows in events that we have written - EXTNAME_URL, - UnknownProperty.CONTENT_ITEM_TYPE - ) - ) - - addOrUpdateRows(batch) - batch.commit() - - return ContentUris.withAppendedId(Events.CONTENT_URI, existingId) - } - } - - fun update(values: ContentValues) { - calendar.client.update(eventSyncURI(), values, null, null) - } - - /** - * Deletes an existing event from the calendar storage. - * - * @return number of affected rows - * - * @throws RemoteException on calendar provider errors - */ - fun delete(): Int { - val batch = CalendarBatchOperation(calendar.client) - - // remove exceptions of event, too (CalendarProvider doesn't do this) - deleteExceptions(batch) - - // remove event and unset known id - batch += CpoBuilder.newDelete(eventSyncURI()) - id = null - - return batch.commit() - } - - private fun deleteExceptions(batch: CalendarBatchOperation) { - val existingId = requireNotNull(id) - batch += CpoBuilder - .newDelete(Events.CONTENT_URI.asSyncAdapter(calendar.account)) - .withSelection("${Events.ORIGINAL_ID}=?", arrayOf(existingId.toString())) - } - - - /** - * Builds an Android [Events] row for a given event. Takes information from - * - * - this [AndroidEvent] object: fields like calendar ID, sync ID, eTag etc, - * - the [event]: all other fields. - * - * @param recurrence event to be used as data source; *null*: use this AndroidEvent's main [event] as source - * @param builder data row builder to be used - */ - private fun buildEvent(recurrence: Event?, builder: CpoBuilder) { - val event = recurrence ?: requireNotNull(event) - - val dtStart = event.dtStart ?: throw InvalidLocalResourceException("Events must have DTSTART") - val allDay = DateUtils.isDate(dtStart) - - // make sure that time zone is supported by Android - AndroidTimeUtils.androidifyTimeZone(dtStart) - - val recurring = event.rRules.isNotEmpty() || event.rDates.isNotEmpty() - - /* [CalendarContract.Events SDK documentation] - When inserting a new event the following fields must be included: - - dtstart - - dtend if the event is non-recurring - - duration if the event is recurring - - rrule or rdate if the event is recurring - - eventTimezone - - a calendar_id */ - - // object-level (AndroidEvent) fields - builder .withValue(Events.CALENDAR_ID, calendar.id) - .withValue(Events.DIRTY, 0) // newly created event rows shall not be marked as dirty - .withValue(Events.DELETED, 0) // or deleted - .withValue(COLUMN_FLAGS, flags) - - if (recurrence == null) - builder.withValue(Events._SYNC_ID, syncId) - .withValue(COLUMN_ETAG, eTag) - .withValue(COLUMN_SCHEDULE_TAG, scheduleTag) - else - builder.withValue(Events.ORIGINAL_SYNC_ID, syncId) - - // UID, sequence - builder .withValue(Events.UID_2445, event.uid) - .withValue(COLUMN_SEQUENCE, event.sequence) - - // time fields - builder .withValue(Events.DTSTART, dtStart.date.time) - .withValue(Events.ALL_DAY, if (allDay) 1 else 0) - .withValue(Events.EVENT_TIMEZONE, AndroidTimeUtils.storageTzId(dtStart)) - - var dtEnd = event.dtEnd - AndroidTimeUtils.androidifyTimeZone(dtEnd) - - var duration = - if (dtEnd == null) - event.duration?.duration - else - null - if (allDay && duration is Duration) - duration = Period.ofDays(duration.toDays().toInt()) - - if (recurring) { - // duration must be set - if (duration == null) { - if (dtEnd != null) { - // calculate duration from dtEnd - duration = if (allDay) - Period.between(dtStart.date.toLocalDate(), dtEnd.date.toLocalDate()) - else - Duration.between(dtStart.date.toInstant(), dtEnd.date.toInstant()) - } else { - // no dtEnd and no duration - duration = if (allDay) - /* [RFC 5545 3.6.1 Event Component] - For cases where a "VEVENT" calendar component - specifies a "DTSTART" property with a DATE value type but no - "DTEND" nor "DURATION" property, the event's duration is taken to - be one day. */ - Period.ofDays(1) - else - /* For cases where a "VEVENT" calendar component - specifies a "DTSTART" property with a DATE-TIME value type but no - "DTEND" property, the event ends on the same calendar date and - time of day specified by the "DTSTART" property. */ - - // Duration.ofSeconds(0) causes the calendar provider to crash - Period.ofDays(0) - } - } - - // iCalendar doesn't permit years and months, only PwWdDThHmMsS - builder .withValue(Events.DURATION, duration?.toRfc5545Duration(dtStart.date.toInstant())) - .withValue(Events.DTEND, null) - - // add RRULEs - if (event.rRules.isNotEmpty()) { - builder.withValue(Events.RRULE, event.rRules - .joinToString(AndroidTimeUtils.RECURRENCE_RULE_SEPARATOR) { it.value }) - } else - builder.withValue(Events.RRULE, null) - - if (event.rDates.isNotEmpty()) { - // ignore RDATEs when there's also an infinite RRULE [https://issuetracker.google.com/issues/216374004] - val infiniteRrule = event.rRules.any { rRule -> - rRule.recur.count == -1 && // no COUNT AND - rRule.recur.until == null // no UNTIL - } - - if (infiniteRrule) - logger.warning("Android can't handle infinite RRULE + RDATE [https://issuetracker.google.com/issues/216374004]; ignoring RDATE(s)") - else { - for (rDate in event.rDates) - AndroidTimeUtils.androidifyTimeZone(rDate) - - // Calendar provider drops DTSTART instance when using RDATE [https://code.google.com/p/android/issues/detail?id=171292] - val listWithDtStart = DateList() - listWithDtStart.add(dtStart.date) - event.rDates.addFirst(RDate(listWithDtStart)) - - builder.withValue(Events.RDATE, AndroidTimeUtils.recurrenceSetsToAndroidString(event.rDates, dtStart.date)) - } - } else - builder.withValue(Events.RDATE, null) - - if (event.exRules.isNotEmpty()) - builder.withValue(Events.EXRULE, event.exRules.joinToString(AndroidTimeUtils.RECURRENCE_RULE_SEPARATOR) { it.value }) - else - builder.withValue(Events.EXRULE, null) - - if (event.exDates.isNotEmpty()) { - for (exDate in event.exDates) - AndroidTimeUtils.androidifyTimeZone(exDate) - builder.withValue(Events.EXDATE, AndroidTimeUtils.recurrenceSetsToAndroidString(event.exDates, dtStart.date)) - } else - builder.withValue(Events.EXDATE, null) - - } else /* !recurring */ { - // dtend must be set - if (dtEnd == null) { - if (duration != null) { - // calculate dtEnd from duration - if (allDay) { - val calcDtEnd = dtStart.date.toLocalDate() + duration - dtEnd = DtEnd(calcDtEnd.toIcal4jDate()) - } else { - val zonedStartTime = (dtStart.date as DateTime).toZonedDateTime() - val calcEnd = zonedStartTime + duration - val calcDtEnd = DtEnd(calcEnd.toIcal4jDateTime()) - calcDtEnd.timeZone = dtStart.timeZone - dtEnd = calcDtEnd - } - } else { - // no dtEnd and no duration - dtEnd = if (allDay) { - /* [RFC 5545 3.6.1 Event Component] - For cases where a "VEVENT" calendar component - specifies a "DTSTART" property with a DATE value type but no - "DTEND" nor "DURATION" property, the event's duration is taken to - be one day. */ - val calcDtEnd = dtStart.date.toLocalDate() + Period.ofDays(1) - DtEnd(calcDtEnd.toIcal4jDate()) - } else - /* For cases where a "VEVENT" calendar component - specifies a "DTSTART" property with a DATE-TIME value type but no - "DTEND" property, the event ends on the same calendar date and - time of day specified by the "DTSTART" property. */ - DtEnd(dtStart.value, dtStart.timeZone) - } - } - - AndroidTimeUtils.androidifyTimeZone(dtEnd) - builder .withValue(Events.DTEND, dtEnd.date.time) - .withValue(Events.EVENT_END_TIMEZONE, AndroidTimeUtils.storageTzId(dtEnd)) - .withValue(Events.DURATION, null) - .withValue(Events.RRULE, null) - .withValue(Events.RDATE, null) - .withValue(Events.EXRULE, null) - .withValue(Events.EXDATE, null) - } - - // text fields - builder.withValue(Events.TITLE, event.summary) - .withValue(Events.EVENT_LOCATION, event.location) - .withValue(Events.DESCRIPTION, event.description) - - // color - val color = event.color - if (color != null) { - // set event color (if it's available for this account) - calendar.client.query(Colors.CONTENT_URI.asSyncAdapter(calendar.account), arrayOf(Colors.COLOR_KEY), - "${Colors.COLOR_KEY}=? AND ${Colors.COLOR_TYPE}=${Colors.TYPE_EVENT}", arrayOf(color.name), null)?.use { cursor -> - if (cursor.moveToNext()) - builder.withValue(Events.EVENT_COLOR_KEY, color.name) - else - logger.fine("Ignoring event color \"${color.name}\" (not available for this account)") - } - } else { - // reset color index and value - builder .withValue(Events.EVENT_COLOR_KEY, null) - .withValue(Events.EVENT_COLOR, null) - } - - // scheduling - val groupScheduled = event.attendees.isNotEmpty() - if (groupScheduled) { - builder .withValue(Events.HAS_ATTENDEE_DATA, 1) - .withValue(Events.ORGANIZER, event.organizer?.let { organizer -> - val uri = organizer.calAddress - val email = if (uri.scheme.equals("mailto", true)) - uri.schemeSpecificPart - else - organizer.getParameter(Parameter.EMAIL)?.value - - if (email != null) - return@let email - - logger.warning("Ignoring ORGANIZER without email address (not supported by Android)") - null - } ?: calendar.ownerAccount) - - } else /* !groupScheduled */ - builder .withValue(Events.HAS_ATTENDEE_DATA, 0) - .withValue(Events.ORGANIZER, calendar.ownerAccount) - - // Attention: don't update event with STATUS != null to STATUS = null (causes calendar provider operation to fail)! - // In this case, the whole event must be deleted and inserted again. - if (/* insert, not an update */ id == null || /* update, but we're not updating to null */ event.status != null) - builder.withValue(Events.STATUS, when (event.status) { - null /* not possible by if statement */ -> null - Status.VEVENT_CONFIRMED -> Events.STATUS_CONFIRMED - Status.VEVENT_CANCELLED -> Events.STATUS_CANCELED - else -> Events.STATUS_TENTATIVE - }) - - builder .withValue(Events.AVAILABILITY, if (event.opaque) Events.AVAILABILITY_BUSY else Events.AVAILABILITY_FREE) - .withValue(Events.ACCESS_LEVEL, when (event.classification) { - null -> Events.ACCESS_DEFAULT - Clazz.PUBLIC -> Events.ACCESS_PUBLIC - Clazz.CONFIDENTIAL -> Events.ACCESS_CONFIDENTIAL - else /* including Events.ACCESS_PRIVATE */ -> Events.ACCESS_PRIVATE - }) - } - - private fun insertReminder(batch: CalendarBatchOperation, idxEvent: Int?, alarm: VAlarm) { - val builder = CpoBuilder - .newInsert(Reminders.CONTENT_URI.asSyncAdapter(calendar.account)) - .withEventId(Reminders.EVENT_ID, idxEvent) - - val method = when (alarm.action?.value?.uppercase(Locale.ROOT)) { - Action.DISPLAY.value, - Action.AUDIO.value -> Reminders.METHOD_ALERT // will trigger an alarm on the Android device - - // Note: The calendar provider doesn't support saving specific attendees for email reminders. - Action.EMAIL.value -> Reminders.METHOD_EMAIL - - else -> Reminders.METHOD_DEFAULT // won't trigger an alarm on the Android device - } - - val minutes = ICalendar.vAlarmToMin(alarm, event!!, false)?.second ?: Reminders.MINUTES_DEFAULT - - builder .withValue(Reminders.METHOD, method) - .withValue(Reminders.MINUTES, minutes) - batch += builder - } - - private fun insertAttendee(batch: CalendarBatchOperation, idxEvent: Int?, attendee: Attendee, organizer: String) { - val builder = CpoBuilder - .newInsert(Attendees.CONTENT_URI.asSyncAdapter(calendar.account)) - .withEventId(Attendees.EVENT_ID, idxEvent) - - val member = attendee.calAddress - if (member.scheme.equals("mailto", true)) - // attendee identified by email - builder .withValue(Attendees.ATTENDEE_EMAIL, member.schemeSpecificPart) - else { - // attendee identified by other URI - builder .withValue(Attendees.ATTENDEE_ID_NAMESPACE, member.scheme) - .withValue(Attendees.ATTENDEE_IDENTITY, member.schemeSpecificPart) - - attendee.getParameter(Parameter.EMAIL)?.let { email -> - builder.withValue(Attendees.ATTENDEE_EMAIL, email.value) - } - } - - attendee.getParameter(Parameter.CN)?.let { cn -> - builder.withValue(Attendees.ATTENDEE_NAME, cn.value) - } - - // type/relation mapping is complex and thus outsourced to AttendeeMappings - AttendeeMappings.iCalendarToAndroid(attendee, builder, organizer) - - val status = when(attendee.getParameter(Parameter.PARTSTAT) as? PartStat) { - PartStat.ACCEPTED -> Attendees.ATTENDEE_STATUS_ACCEPTED - PartStat.DECLINED -> Attendees.ATTENDEE_STATUS_DECLINED - PartStat.TENTATIVE -> Attendees.ATTENDEE_STATUS_TENTATIVE - PartStat.DELEGATED -> Attendees.ATTENDEE_STATUS_NONE - else /* default: PartStat.NEEDS_ACTION */ -> Attendees.ATTENDEE_STATUS_INVITED - } - builder.withValue(Attendees.ATTENDEE_STATUS, status) - batch += builder - } - - private fun insertExtendedProperty(batch: CalendarBatchOperation, idxEvent: Int?, name: String, value: String) { - val builder = CpoBuilder - .newInsert(ExtendedProperties.CONTENT_URI.asSyncAdapter(calendar.account)) - .withEventId(ExtendedProperties.EVENT_ID, idxEvent) - .withValue(ExtendedProperties.NAME, name) - .withValue(ExtendedProperties.VALUE, value) - batch += builder - } - - private fun insertCategories(batch: CalendarBatchOperation, idxEvent: Int?) { - val rawCategories = event!!.categories // concatenate, separate by backslash - .joinToString(CATEGORIES_SEPARATOR.toString()) { category -> - // drop occurrences of CATEGORIES_SEPARATOR in category names - category.filter { it != CATEGORIES_SEPARATOR } - } - insertExtendedProperty(batch, idxEvent, EXTNAME_CATEGORIES, rawCategories) - } - - private fun insertUnknownProperty(batch: CalendarBatchOperation, idxEvent: Int?, property: Property) { - if (property.value == null) { - logger.warning("Ignoring unknown property with null value") - return - } - if (property.value.length > UnknownProperty.MAX_UNKNOWN_PROPERTY_SIZE) { - logger.warning("Ignoring unknown property with ${property.value.length} octets (too long)") - return - } - - insertExtendedProperty(batch, idxEvent, UnknownProperty.CONTENT_ITEM_TYPE, UnknownProperty.toJsonString(property)) - } - - private fun useRetainedClassification() { - val event = requireNotNull(event) - - var retainedClazz: Clazz? = null - val it = event.unknownProperties.iterator() - while (it.hasNext()) { - val prop = it.next() - if (prop is Clazz) { - retainedClazz = prop - it.remove() - } - } - - if (event.classification == null) - // no classification, use retained one if possible - event.classification = retainedClazz - } - - - private fun CpoBuilder.withEventId(column: String, idxEvent: Int?): CpoBuilder { - if (idxEvent != null) - withValueBackReference(column, idxEvent) - else - withValue(column, requireNotNull(id)) - return this - } - - - private fun eventSyncURI(): Uri { - val id = requireNotNull(id) - return ContentUris.withAppendedId(Events.CONTENT_URI, id).asSyncAdapter(calendar.account) - } - - override fun toString(): String = "AndroidEvent(calendar=$calendar, id=$id, event=$_event)" - - - companion object { - - const val MUTATORS_SEPARATOR = ',' - - /** - * Custom sync column to store the last known ETag of an event. - */ - const val COLUMN_ETAG = Events.SYNC_DATA1 - - /** - * Custom sync column to store sync flags of an event. - */ - const val COLUMN_FLAGS = Events.SYNC_DATA2 - - /** - * Custom sync column to store the SEQUENCE of an event. - */ - const val COLUMN_SEQUENCE = Events.SYNC_DATA3 - - /** - * Custom sync column to store the Schedule-Tag of an event. - */ - const val COLUMN_SCHEDULE_TAG = Events.SYNC_DATA4 - - /** - * VEVENT CATEGORIES are 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 [CATEGORIES_SEPARATOR]. (If a category - * value contains [CATEGORIES_SEPARATOR], [CATEGORIES_SEPARATOR] will be dropped.) - * - * Example: `Cat1\Cat2` - */ - const val EXTNAME_CATEGORIES = "categories" - const val CATEGORIES_SEPARATOR = '\\' - - /** - * Google Calendar uses an extended property called `iCalUid` for storing the event's UID, instead of the - * standard [Events.UID_2445]. - * - * @see GitHub Issue - */ - const val EXTNAME_ICAL_UID = "iCalUid" - - /** - * VEVENT URL is stored as an extended property with this [ExtendedProperties.NAME]. - * The URL is directly put into [ExtendedProperties.VALUE]. - */ - const val EXTNAME_URL = ContentResolver.CURSOR_ITEM_BASE_TYPE + "/vnd.ical4android.url" - - - // helpers - - /** - * Marks the event as deleted - * @param eventID - */ - fun markAsDeleted(provider: ContentProviderClient, account: Account, eventID: Long) { - provider.update( - ContentUris.withAppendedId( - Events.CONTENT_URI, - eventID - ).asSyncAdapter(account), - contentValuesOf(Events.DELETED to 1), - null, null - ) - } - - /** - * Finds the amount of direct instances this event has (without exceptions); used by [numInstances] - * to find the number of instances of exceptions. - * - * The number of returned instances may vary with the Android version. - * - * @return number of direct event instances (not counting instances of exceptions); *null* if - * the number can't be determined or if the event has no last date (recurring event without last instance) - */ - fun numDirectInstances(provider: ContentProviderClient, account: Account, eventID: Long): Int? { - // query event to get first and last instance - var first: Long? = null - var last: Long? = null - provider.query( - ContentUris.withAppendedId( - Events.CONTENT_URI, - eventID - ), - arrayOf(Events.DTSTART, Events.LAST_DATE), null, null, null - )?.use { cursor -> - cursor.moveToNext() - if (!cursor.isNull(0)) - first = cursor.getLong(0) - if (!cursor.isNull(1)) - last = cursor.getLong(1) - } - // if this event doesn't have a last occurence, it's endless and always has instances - if (first == null || last == null) - return null - - /* We can't use Long.MIN_VALUE and Long.MAX_VALUE because Android generates the instances - on the fly and it doesn't accept those values. So we use the first/last actual occurence - of the event (calculated by Android). */ - val instancesUri = CalendarContract.Instances.CONTENT_URI.asSyncAdapter(account) - .buildUpon() - .appendPath(first.toString()) // begin timestamp - .appendPath(last.toString()) // end timestamp - .build() - - var numInstances = 0 - provider.query( - instancesUri, null, - "${CalendarContract.Instances.EVENT_ID}=?", arrayOf(eventID.toString()), - null - )?.use { cursor -> - numInstances += cursor.count - } - return numInstances - } - - /** - * Finds the total number of instances this event has (including instances of exceptions) - * - * The number of returned instances may vary with the Android version. - * - * @return number of direct event instances (not counting instances of exceptions); *null* if - * the number can't be determined or if the event has no last date (recurring event without last instance) - */ - fun numInstances(provider: ContentProviderClient, account: Account, eventID: Long): Int? { - // num instances of the main event - var numInstances = numDirectInstances(provider, account, eventID) ?: return null - - // add the number of instances of every main event's exception - provider.query( - Events.CONTENT_URI, - arrayOf(Events._ID), - "${Events.ORIGINAL_ID}=?", // get exception events of the main event - arrayOf("$eventID"), null - )?.use { exceptionsEventCursor -> - while (exceptionsEventCursor.moveToNext()) { - val exceptionEventID = exceptionsEventCursor.getLong(0) - val exceptionInstances = numDirectInstances(provider, account, exceptionEventID) - - if (exceptionInstances == null) - // number of instances of exception can't be determined; so the total number of instances is also unclear - return null - - numInstances += exceptionInstances - } - } - return numInstances - } - - } - -} \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/DmfsTask.kt b/lib/src/main/kotlin/at/bitfire/ical4android/DmfsTask.kt index 2d6b9bb9..0c6ac24a 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/DmfsTask.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/DmfsTask.kt @@ -16,6 +16,7 @@ import at.bitfire.ical4android.util.DateUtils import at.bitfire.synctools.storage.BatchOperation.CpoBuilder import at.bitfire.synctools.storage.LocalStorageException import at.bitfire.synctools.storage.TasksBatchOperation +import at.bitfire.synctools.storage.calendar.UnknownProperty import at.bitfire.synctools.storage.toContentValues import net.fortuna.ical4j.model.Date import net.fortuna.ical4j.model.DateTime diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/Event.kt b/lib/src/main/kotlin/at/bitfire/ical4android/Event.kt index 4d85eb77..18c7c9fc 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/Event.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/Event.kt @@ -243,7 +243,7 @@ data class Event( get() = Logger.getLogger(Event::class.java.name) /** - * Parses an iCalendar resource, applies [at.bitfire.ical4android.validation.ICalPreprocessor] + * Parses an iCalendar resource, applies [at.bitfire.synctools.icalendar.validation.ICalPreprocessor] * and [EventValidator] to increase compatibility and extracts the VEVENTs. * * @param reader where the iCalendar is taken from diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/ICalendar.kt b/lib/src/main/kotlin/at/bitfire/ical4android/ICalendar.kt index a80827ae..1b607b8e 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/ICalendar.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/ICalendar.kt @@ -7,10 +7,10 @@ package at.bitfire.ical4android import at.bitfire.ical4android.ICalendar.Companion.CALENDAR_NAME -import at.bitfire.ical4android.validation.ICalPreprocessor import at.bitfire.synctools.BuildConfig import at.bitfire.synctools.exception.InvalidRemoteResourceException import at.bitfire.synctools.icalendar.ICalendarParser +import at.bitfire.synctools.icalendar.validation.ICalPreprocessor import net.fortuna.ical4j.data.CalendarBuilder import net.fortuna.ical4j.data.ParserException import net.fortuna.ical4j.model.Calendar diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/Ical4jVersion.kt b/lib/src/main/kotlin/at/bitfire/ical4android/Ical4jVersion.kt deleted file mode 100644 index 2f9613aa..00000000 --- a/lib/src/main/kotlin/at/bitfire/ical4android/Ical4jVersion.kt +++ /dev/null @@ -1,15 +0,0 @@ -/* - * This file is part of bitfireAT/synctools which is released under GPLv3. - * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package at.bitfire.ical4android - -import at.bitfire.synctools.BuildConfig - -/** - * The used version of ical4j. - */ -@Suppress("unused") -const val ical4jVersion = BuildConfig.version_ical4j diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt b/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt index 2710bcba..4458b2b6 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt @@ -18,6 +18,7 @@ import at.bitfire.synctools.exception.InvalidRemoteResourceException import at.bitfire.synctools.icalendar.Css3Color import at.bitfire.synctools.storage.BatchOperation import at.bitfire.synctools.storage.JtxBatchOperation +import at.bitfire.synctools.storage.calendar.UnknownProperty import at.bitfire.synctools.storage.toContentValues import at.techbee.jtx.JtxContract import at.techbee.jtx.JtxContract.JtxICalObject.TZ_ALLDAY diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/Task.kt b/lib/src/main/kotlin/at/bitfire/ical4android/Task.kt index dbff29d8..c27ad76b 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/Task.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/Task.kt @@ -100,7 +100,7 @@ data class Task( get() = Logger.getLogger(Task::class.java.name) /** - * Parses an iCalendar resource, applies [at.bitfire.ical4android.validation.ICalPreprocessor] to increase compatibility + * Parses an iCalendar resource, applies [at.bitfire.synctools.icalendar.validation.ICalPreprocessor] to increase compatibility * and extracts the VTODOs. * * @param reader where the iCalendar is taken from diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/AndroidCompatTimeZoneRegistry.kt b/lib/src/main/kotlin/at/bitfire/synctools/icalendar/AndroidCompatTimeZoneRegistry.kt similarity index 90% rename from lib/src/main/kotlin/at/bitfire/ical4android/AndroidCompatTimeZoneRegistry.kt rename to lib/src/main/kotlin/at/bitfire/synctools/icalendar/AndroidCompatTimeZoneRegistry.kt index 4d71c40b..e8abfa3c 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/AndroidCompatTimeZoneRegistry.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/icalendar/AndroidCompatTimeZoneRegistry.kt @@ -4,29 +4,28 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -package at.bitfire.ical4android +package at.bitfire.synctools.icalendar -import java.time.ZoneId -import java.util.logging.Logger import net.fortuna.ical4j.model.DefaultTimeZoneRegistryFactory import net.fortuna.ical4j.model.Property import net.fortuna.ical4j.model.PropertyList import net.fortuna.ical4j.model.TimeZone import net.fortuna.ical4j.model.TimeZoneRegistry import net.fortuna.ical4j.model.TimeZoneRegistryFactory -import net.fortuna.ical4j.model.TimeZoneRegistryImpl import net.fortuna.ical4j.model.component.VTimeZone import net.fortuna.ical4j.model.property.TzId +import java.time.ZoneId +import java.util.logging.Logger /** - * Wrapper around default [TimeZoneRegistry] that uses the Android name if a time zone has a + * Wrapper around default [net.fortuna.ical4j.model.TimeZoneRegistry] that uses the Android name if a time zone has a * different name in ical4j and Android. * * **This time zone registry is set as default registry for ical4android projects in * resources/ical4j.properties.** * * For instance, if a time zone is known as "Europe/Kyiv" (with alias "Europe/Kiev") in ical4j - * and only "Europe/Kiev" in Android, this registry behaves like the default [TimeZoneRegistryImpl], + * and only "Europe/Kiev" in Android, this registry behaves like the default [net.fortuna.ical4j.model.TimeZoneRegistryImpl], * but the returned time zone for `getTimeZone("Europe/Kiev")` has an ID of "Europe/Kiev" and not * "Europe/Kyiv". */ @@ -80,10 +79,12 @@ class AndroidCompatTimeZoneRegistry( val vTimeZone = tz.vTimeZone val newVTimeZoneProperties = PropertyList() newVTimeZoneProperties += TzId(androidTzId) - return TimeZone(VTimeZone( - newVTimeZoneProperties, - vTimeZone.observances - )) + return TimeZone( + VTimeZone( + newVTimeZoneProperties, + vTimeZone.observances + ) + ) } else return tz } diff --git a/lib/src/main/kotlin/at/bitfire/synctools/icalendar/ICalendarParser.kt b/lib/src/main/kotlin/at/bitfire/synctools/icalendar/ICalendarParser.kt index aec4ae70..be08f2f8 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/icalendar/ICalendarParser.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/icalendar/ICalendarParser.kt @@ -6,8 +6,8 @@ package at.bitfire.synctools.icalendar -import at.bitfire.ical4android.validation.ICalPreprocessor import at.bitfire.synctools.exception.InvalidRemoteResourceException +import at.bitfire.synctools.icalendar.validation.ICalPreprocessor import net.fortuna.ical4j.data.CalendarBuilder import net.fortuna.ical4j.data.CalendarParserFactory import net.fortuna.ical4j.data.ContentHandlerContext diff --git a/lib/src/main/kotlin/at/bitfire/synctools/icalendar/Ical4jHelpers.kt b/lib/src/main/kotlin/at/bitfire/synctools/icalendar/Ical4jHelpers.kt index 7fa6dec8..4a52961a 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/icalendar/Ical4jHelpers.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/icalendar/Ical4jHelpers.kt @@ -6,6 +6,7 @@ package at.bitfire.synctools.icalendar +import at.bitfire.synctools.BuildConfig import net.fortuna.ical4j.model.ComponentList import net.fortuna.ical4j.model.Property import net.fortuna.ical4j.model.PropertyList @@ -15,6 +16,12 @@ import net.fortuna.ical4j.model.property.Sequence import net.fortuna.ical4j.model.property.Uid +/** + * The used version of ical4j. + */ +@Suppress("unused") +const val ical4jVersion = BuildConfig.version_ical4j + fun componentListOf(vararg components: T) = ComponentList().apply { addAll(components) diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/validation/FixInvalidDayOffsetPreprocessor.kt b/lib/src/main/kotlin/at/bitfire/synctools/icalendar/validation/FixInvalidDayOffsetPreprocessor.kt similarity index 96% rename from lib/src/main/kotlin/at/bitfire/ical4android/validation/FixInvalidDayOffsetPreprocessor.kt rename to lib/src/main/kotlin/at/bitfire/synctools/icalendar/validation/FixInvalidDayOffsetPreprocessor.kt index 268663bc..cbed9ea2 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/validation/FixInvalidDayOffsetPreprocessor.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/icalendar/validation/FixInvalidDayOffsetPreprocessor.kt @@ -4,7 +4,7 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -package at.bitfire.ical4android.validation +package at.bitfire.synctools.icalendar.validation /** * Fixes durations with day offsets with the 'T' prefix. diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/validation/FixInvalidUtcOffsetPreprocessor.kt b/lib/src/main/kotlin/at/bitfire/synctools/icalendar/validation/FixInvalidUtcOffsetPreprocessor.kt similarity index 88% rename from lib/src/main/kotlin/at/bitfire/ical4android/validation/FixInvalidUtcOffsetPreprocessor.kt rename to lib/src/main/kotlin/at/bitfire/synctools/icalendar/validation/FixInvalidUtcOffsetPreprocessor.kt index 8acdb112..791d5dcb 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/validation/FixInvalidUtcOffsetPreprocessor.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/icalendar/validation/FixInvalidUtcOffsetPreprocessor.kt @@ -4,9 +4,9 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -package at.bitfire.ical4android.validation +package at.bitfire.synctools.icalendar.validation -import at.bitfire.ical4android.validation.FixInvalidUtcOffsetPreprocessor.TZOFFSET_REGEXP +import at.bitfire.synctools.icalendar.validation.FixInvalidUtcOffsetPreprocessor.TZOFFSET_REGEXP import java.util.logging.Level import java.util.logging.Logger diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/validation/ICalPreprocessor.kt b/lib/src/main/kotlin/at/bitfire/synctools/icalendar/validation/ICalPreprocessor.kt similarity index 95% rename from lib/src/main/kotlin/at/bitfire/ical4android/validation/ICalPreprocessor.kt rename to lib/src/main/kotlin/at/bitfire/synctools/icalendar/validation/ICalPreprocessor.kt index 0a306b76..854ed1ae 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/validation/ICalPreprocessor.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/icalendar/validation/ICalPreprocessor.kt @@ -4,8 +4,9 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -package at.bitfire.ical4android.validation +package at.bitfire.synctools.icalendar.validation +import at.bitfire.synctools.icalendar.validation.ICalPreprocessor.streamPreprocessors import net.fortuna.ical4j.model.Calendar import net.fortuna.ical4j.model.Property import net.fortuna.ical4j.transform.rfc5545.CreatedPropertyRule diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/validation/StreamPreprocessor.kt b/lib/src/main/kotlin/at/bitfire/synctools/icalendar/validation/StreamPreprocessor.kt similarity index 97% rename from lib/src/main/kotlin/at/bitfire/ical4android/validation/StreamPreprocessor.kt rename to lib/src/main/kotlin/at/bitfire/synctools/icalendar/validation/StreamPreprocessor.kt index e11044d8..53e5cc87 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/validation/StreamPreprocessor.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/icalendar/validation/StreamPreprocessor.kt @@ -4,7 +4,7 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -package at.bitfire.ical4android.validation +package at.bitfire.synctools.icalendar.validation import java.io.IOException import java.io.Reader diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/AttendeeMappings.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/AttendeeMappings.kt similarity index 65% rename from lib/src/main/kotlin/at/bitfire/ical4android/AttendeeMappings.kt rename to lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/AttendeeMappings.kt index dbee7016..d0cb9c4f 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/AttendeeMappings.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/AttendeeMappings.kt @@ -4,11 +4,10 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -package at.bitfire.ical4android +package at.bitfire.synctools.mapping.calendar import android.content.ContentValues import android.provider.CalendarContract -import android.provider.CalendarContract.Attendees import at.bitfire.synctools.storage.BatchOperation import net.fortuna.ical4j.model.Parameter import net.fortuna.ical4j.model.parameter.CuType @@ -17,7 +16,7 @@ import net.fortuna.ical4j.model.parameter.Role import net.fortuna.ical4j.model.property.Attendee /** - * Defines mappings between Android [CalendarContract.Attendees] and iCalendar parameters. + * Defines mappings between Android [android.provider.CalendarContract.Attendees] and iCalendar parameters. * * Because the available Android values are quite different from the one in iCalendar, the * mapping is very lossy. Some special mapping rules are defined: @@ -29,8 +28,8 @@ import net.fortuna.ical4j.model.property.Attendee object AttendeeMappings { /** - * Maps Android [Attendees.ATTENDEE_TYPE] and [Attendees.ATTENDEE_RELATIONSHIP] to - * iCalendar [CuType] and [Role] according to this matrix: + * Maps Android [android.provider.CalendarContract.AttendeesColumns.ATTENDEE_TYPE] and [android.provider.CalendarContract.AttendeesColumns.ATTENDEE_RELATIONSHIP] to + * iCalendar [net.fortuna.ical4j.model.parameter.CuType] and [net.fortuna.ical4j.model.parameter.Role] according to this matrix: * * TYPE ↓ / RELATIONSHIP → ATTENDEE¹ PERFORMER SPEAKER NONE * REQUIRED indᴰ,reqᴰ gro,reqᴰ indᴰ,cha unk,reqᴰ @@ -45,30 +44,30 @@ object AttendeeMappings { * @param attendee iCalendar attendee to fill */ fun androidToICalendar(row: ContentValues, attendee: Attendee) { - val type = row.getAsInteger(Attendees.ATTENDEE_TYPE) ?: Attendees.TYPE_NONE - val relationship = row.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP) ?: Attendees.RELATIONSHIP_NONE + val type = row.getAsInteger(CalendarContract.Attendees.ATTENDEE_TYPE) ?: CalendarContract.Attendees.TYPE_NONE + val relationship = row.getAsInteger(CalendarContract.Attendees.ATTENDEE_RELATIONSHIP) ?: CalendarContract.Attendees.RELATIONSHIP_NONE var cuType: CuType? = null val role: Role? - if (relationship == Attendees.RELATIONSHIP_SPEAKER) { + if (relationship == CalendarContract.Attendees.RELATIONSHIP_SPEAKER) { role = Role.CHAIR - if (type == Attendees.TYPE_RESOURCE) + if (type == CalendarContract.Attendees.TYPE_RESOURCE) cuType = CuType.RESOURCE } else /* relationship != Attendees.RELATIONSHIP_SPEAKER */ { cuType = when (relationship) { - Attendees.RELATIONSHIP_PERFORMER -> CuType.GROUP - Attendees.RELATIONSHIP_NONE -> CuType.UNKNOWN + CalendarContract.Attendees.RELATIONSHIP_PERFORMER -> CuType.GROUP + CalendarContract.Attendees.RELATIONSHIP_NONE -> CuType.UNKNOWN else -> CuType.INDIVIDUAL } when (type) { - Attendees.TYPE_OPTIONAL -> role = Role.OPT_PARTICIPANT - Attendees.TYPE_RESOURCE -> { + CalendarContract.Attendees.TYPE_OPTIONAL -> role = Role.OPT_PARTICIPANT + CalendarContract.Attendees.TYPE_RESOURCE -> { cuType = - if (relationship == Attendees.RELATIONSHIP_PERFORMER) + if (relationship == CalendarContract.Attendees.RELATIONSHIP_PERFORMER) CuType.ROOM else CuType.RESOURCE @@ -88,8 +87,8 @@ object AttendeeMappings { /** - * Maps iCalendar [CuType] and [Role] to Android [Attendees.ATTENDEE_TYPE] and - * [Attendees.ATTENDEE_RELATIONSHIP] according to this matrix: + * Maps iCalendar [CuType] and [Role] to Android [CalendarContract.AttendeesColumns.ATTENDEE_TYPE] and + * [CalendarContract.AttendeesColumns.ATTENDEE_RELATIONSHIP] according to this matrix: * * CuType ↓ / Role → CHAIR REQ-PARTICIPANT¹ᴰ OPT-PARTICIPANT NON-PARTICIPANT * INDIVIDUALᴰ req,spk req,att opt,att non,att @@ -118,45 +117,45 @@ object AttendeeMappings { when (cuType) { CuType.RESOURCE -> { - type = Attendees.TYPE_RESOURCE + type = CalendarContract.Attendees.TYPE_RESOURCE relationship = if (role == Role.CHAIR) - Attendees.RELATIONSHIP_SPEAKER + CalendarContract.Attendees.RELATIONSHIP_SPEAKER else - Attendees.RELATIONSHIP_NONE + CalendarContract.Attendees.RELATIONSHIP_NONE } CuType.ROOM -> { - type = Attendees.TYPE_RESOURCE - relationship = Attendees.RELATIONSHIP_PERFORMER + type = CalendarContract.Attendees.TYPE_RESOURCE + relationship = CalendarContract.Attendees.RELATIONSHIP_PERFORMER } else -> { // not a room and not a resource -> individual (default), group or unknown (includes x-custom) relationship = when (cuType) { CuType.GROUP -> - Attendees.RELATIONSHIP_PERFORMER + CalendarContract.Attendees.RELATIONSHIP_PERFORMER CuType.UNKNOWN -> - Attendees.RELATIONSHIP_NONE + CalendarContract.Attendees.RELATIONSHIP_NONE else -> /* CuType.INDIVIDUAL and custom/unknown values */ - Attendees.RELATIONSHIP_ATTENDEE + CalendarContract.Attendees.RELATIONSHIP_ATTENDEE } when (role) { Role.CHAIR -> { - type = Attendees.TYPE_REQUIRED - relationship = Attendees.RELATIONSHIP_SPEAKER + type = CalendarContract.Attendees.TYPE_REQUIRED + relationship = CalendarContract.Attendees.RELATIONSHIP_SPEAKER } Role.OPT_PARTICIPANT -> - type = Attendees.TYPE_OPTIONAL + type = CalendarContract.Attendees.TYPE_OPTIONAL Role.NON_PARTICIPANT -> - type = Attendees.TYPE_NONE + type = CalendarContract.Attendees.TYPE_NONE else -> /* Role.REQ_PARTICIPANT and custom/unknown values */ - type = Attendees.TYPE_REQUIRED + type = CalendarContract.Attendees.TYPE_REQUIRED } } } - if (relationship == Attendees.RELATIONSHIP_ATTENDEE) { + if (relationship == CalendarContract.Attendees.RELATIONSHIP_ATTENDEE) { val uri = attendee.calAddress val email = if (uri.scheme.equals("mailto", true)) uri.schemeSpecificPart @@ -164,11 +163,11 @@ object AttendeeMappings { attendee.getParameter(Parameter.EMAIL)?.value if (email == organizer) - relationship = Attendees.RELATIONSHIP_ORGANIZER + relationship = CalendarContract.Attendees.RELATIONSHIP_ORGANIZER } - row .withValue(Attendees.ATTENDEE_TYPE, type) - .withValue(Attendees.ATTENDEE_RELATIONSHIP, relationship) + row .withValue(CalendarContract.Attendees.ATTENDEE_TYPE, type) + .withValue(CalendarContract.Attendees.ATTENDEE_RELATIONSHIP, relationship) } } \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventBuilder.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventBuilder.kt new file mode 100644 index 00000000..14a47c14 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventBuilder.kt @@ -0,0 +1,541 @@ +/* + * This file is part of bitfireAT/synctools which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.synctools.mapping.calendar + +import android.provider.CalendarContract.Attendees +import android.provider.CalendarContract.Colors +import android.provider.CalendarContract.Events +import android.provider.CalendarContract.ExtendedProperties +import android.provider.CalendarContract.Reminders +import at.bitfire.ical4android.Event +import at.bitfire.ical4android.ICalendar +import at.bitfire.ical4android.util.AndroidTimeUtils +import at.bitfire.ical4android.util.DateUtils +import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter +import at.bitfire.ical4android.util.TimeApiExtensions.requireZoneId +import at.bitfire.ical4android.util.TimeApiExtensions.toIcal4jDate +import at.bitfire.ical4android.util.TimeApiExtensions.toIcal4jDateTime +import at.bitfire.ical4android.util.TimeApiExtensions.toLocalDate +import at.bitfire.ical4android.util.TimeApiExtensions.toLocalTime +import at.bitfire.ical4android.util.TimeApiExtensions.toRfc5545Duration +import at.bitfire.ical4android.util.TimeApiExtensions.toZonedDateTime +import at.bitfire.synctools.exception.InvalidLocalResourceException +import at.bitfire.synctools.storage.BatchOperation.CpoBuilder +import at.bitfire.synctools.storage.calendar.AndroidCalendar +import at.bitfire.synctools.storage.calendar.AndroidEvent +import at.bitfire.synctools.storage.calendar.AndroidEvent.Companion.CATEGORIES_SEPARATOR +import at.bitfire.synctools.storage.calendar.AndroidEvent.Companion.COLUMN_ETAG +import at.bitfire.synctools.storage.calendar.AndroidEvent.Companion.COLUMN_FLAGS +import at.bitfire.synctools.storage.calendar.AndroidEvent.Companion.COLUMN_SCHEDULE_TAG +import at.bitfire.synctools.storage.calendar.AndroidEvent.Companion.COLUMN_SEQUENCE +import at.bitfire.synctools.storage.calendar.AndroidEvent.Companion.EXTNAME_CATEGORIES +import at.bitfire.synctools.storage.calendar.AndroidEvent.Companion.EXTNAME_URL +import at.bitfire.synctools.storage.calendar.CalendarBatchOperation +import at.bitfire.synctools.storage.calendar.UnknownProperty +import net.fortuna.ical4j.model.Date +import net.fortuna.ical4j.model.DateList +import net.fortuna.ical4j.model.DateTime +import net.fortuna.ical4j.model.Parameter +import net.fortuna.ical4j.model.Property +import net.fortuna.ical4j.model.component.VAlarm +import net.fortuna.ical4j.model.parameter.Cn +import net.fortuna.ical4j.model.parameter.Email +import net.fortuna.ical4j.model.parameter.PartStat +import net.fortuna.ical4j.model.property.Action +import net.fortuna.ical4j.model.property.Attendee +import net.fortuna.ical4j.model.property.Clazz +import net.fortuna.ical4j.model.property.DtEnd +import net.fortuna.ical4j.model.property.RDate +import net.fortuna.ical4j.model.property.Status +import java.time.Duration +import java.time.Period +import java.time.ZonedDateTime +import java.util.Locale +import java.util.logging.Logger + +/** + * Legacy mapper from an [Event] data object to Android content provider data rows + * (former "build..." methods). + * + * Important: To use recurrence exceptions, you MUST set _SYNC_ID and ORIGINAL_SYNC_ID + * in populateEvent() / buildEvent. Setting _ID and ORIGINAL_ID is not sufficient. + */ +class LegacyAndroidEventBuilder( + private val calendar: AndroidCalendar, + private val event: Event, + + // AndroidEvent-level fields + private val id: Long?, + private val syncId: String?, + private val eTag: String?, + private val scheduleTag: String?, + private val flags: Int, +) { + + private val logger + get() = Logger.getLogger(javaClass.name) + + fun addOrUpdateRows(event: Event, batch: CalendarBatchOperation): Int? { + val builder = + if (id == null) + CpoBuilder.newInsert(calendar.eventsUri) + else + CpoBuilder.newUpdate(calendar.eventUri(id)) + + // return the index of the row containing the event ID in the results (only when adding an event) + val idxEvent = if (id == null) + batch.nextBackrefIdx() + else + null + + buildEvent(null, builder) + batch += builder + + // add reminders + event.alarms.forEach { insertReminder(batch, idxEvent, it) } + + // add attendees + val organizer = event.organizerEmail ?: + /* no ORGANIZER, use current account owner as ORGANIZER */ + calendar.ownerAccount ?: calendar.account.name + event.attendees.forEach { insertAttendee(batch, idxEvent, it, organizer) } + + // add extended properties + // CATEGORIES + if (event.categories.isNotEmpty()) + insertCategories(batch, idxEvent) + // CLASS + retainClassification() + // URL + event.url?.let { url -> + insertExtendedProperty(batch, idxEvent, EXTNAME_URL, url.toString()) + } + // unknown properties + event.unknownProperties.forEach { + insertUnknownProperty(batch, idxEvent, it) + } + + // add exceptions + for (exception in event.exceptions) { + /* I guess exceptions should be inserted using Events.CONTENT_EXCEPTION_URI so that we could + benefit from some provider logic (for recurring exceptions e.g.). However, this method + has some caveats: + - For instance, only Events.SYNC_DATA1, SYNC_DATA3 and SYNC_DATA7 can be used + in exception events (that's hardcoded in the CalendarProvider, don't ask me why). + - Also, CONTENT_EXCEPTIONS_URI doesn't deal with exceptions for recurring events defined by RDATE + (it checks for RRULE and aborts if no RRULE is found). + So I have chosen the method of inserting the exception event manually. + + It's also noteworthy that linking the main event to the exception only works using _SYNC_ID + and ORIGINAL_SYNC_ID (and not ID and ORIGINAL_ID, as one could assume). So, if you don't + set _SYNC_ID in the main event and ORIGINAL_SYNC_ID in the exception, the exception will + appear additionally (and not *instead* of the instance). + */ + + val recurrenceId = exception.recurrenceId + if (recurrenceId == null) { + logger.warning("Ignoring exception of event ${event.uid} without recurrenceId") + continue + } + + val exBuilder = CpoBuilder + .newInsert(Events.CONTENT_URI.asSyncAdapter(calendar.account)) + .withEventId(Events.ORIGINAL_ID, idxEvent) + + buildEvent(exception, exBuilder) + if (exBuilder.values[Events.ORIGINAL_SYNC_ID] == null && exBuilder.valueBackrefs[Events.ORIGINAL_SYNC_ID] == null) + throw AssertionError("buildEvent(exception) must set ORIGINAL_SYNC_ID") + + var recurrenceDate = recurrenceId.date + val dtStartDate = event.dtStart!!.date + if (recurrenceDate is DateTime && dtStartDate !is DateTime) { + // rewrite RECURRENCE-ID;VALUE=DATE-TIME to VALUE=DATE for all-day events + val localDate = recurrenceDate.toLocalDate() + recurrenceDate = Date(localDate.toIcal4jDate()) + + } else if (recurrenceDate !is DateTime && dtStartDate is DateTime) { + // rewrite RECURRENCE-ID;VALUE=DATE to VALUE=DATE-TIME for non-all-day-events + val localDate = recurrenceDate.toLocalDate() + // guess time and time zone from DTSTART + val zonedTime = ZonedDateTime.of( + localDate, + dtStartDate.toLocalTime(), + dtStartDate.requireZoneId() + ) + recurrenceDate = zonedTime.toIcal4jDateTime() + } + exBuilder .withValue(Events.ORIGINAL_ALL_DAY, if (DateUtils.isDate(event.dtStart)) 1 else 0) + .withValue(Events.ORIGINAL_INSTANCE_TIME, recurrenceDate.time) + + val idxException = batch.nextBackrefIdx() + batch += exBuilder + + // add exception reminders + exception.alarms.forEach { insertReminder(batch, idxException, it) } + + // add exception attendees + exception.attendees.forEach { insertAttendee(batch, idxException, it, organizer) } + } + + return idxEvent + } + + /** + * Builds an Android [Events] row for a given event. Takes information from + * + * - this [AndroidEvent] object: fields like calendar ID, sync ID, eTag etc, + * - the [event]: all other fields. + * + * @param recurrence event to be used as data source; *null*: use this AndroidEvent's main [event] as source + * @param builder data row builder to be used + */ + private fun buildEvent(recurrence: Event?, builder: CpoBuilder) { + val event = recurrence ?: event + + val dtStart = event.dtStart ?: throw InvalidLocalResourceException("Events must have DTSTART") + val allDay = DateUtils.isDate(dtStart) + + // make sure that time zone is supported by Android + AndroidTimeUtils.androidifyTimeZone(dtStart) + + val recurring = event.rRules.isNotEmpty() || event.rDates.isNotEmpty() + + /* [CalendarContract.Events SDK documentation] + When inserting a new event the following fields must be included: + - dtstart + - dtend if the event is non-recurring + - duration if the event is recurring + - rrule or rdate if the event is recurring + - eventTimezone + - a calendar_id */ + + // object-level (AndroidEvent) fields + builder .withValue(Events.CALENDAR_ID, calendar.id) + .withValue(Events.DIRTY, 0) // newly created event rows shall not be marked as dirty + .withValue(Events.DELETED, 0) // or deleted + .withValue(COLUMN_FLAGS, flags) + + if (recurrence == null) + builder.withValue(Events._SYNC_ID, syncId) + .withValue(COLUMN_ETAG, eTag) + .withValue(COLUMN_SCHEDULE_TAG, scheduleTag) + else + builder.withValue(Events.ORIGINAL_SYNC_ID, syncId) + + // UID, sequence + builder .withValue(Events.UID_2445, event.uid) + .withValue(COLUMN_SEQUENCE, event.sequence) + + // time fields + builder .withValue(Events.DTSTART, dtStart.date.time) + .withValue(Events.ALL_DAY, if (allDay) 1 else 0) + .withValue(Events.EVENT_TIMEZONE, AndroidTimeUtils.storageTzId(dtStart)) + + var dtEnd = event.dtEnd + AndroidTimeUtils.androidifyTimeZone(dtEnd) + + var duration = + if (dtEnd == null) + event.duration?.duration + else + null + if (allDay && duration is Duration) + duration = Period.ofDays(duration.toDays().toInt()) + + if (recurring) { + // duration must be set + if (duration == null) { + if (dtEnd != null) { + // calculate duration from dtEnd + duration = if (allDay) + Period.between(dtStart.date.toLocalDate(), dtEnd.date.toLocalDate()) + else + Duration.between(dtStart.date.toInstant(), dtEnd.date.toInstant()) + } else { + // no dtEnd and no duration + duration = if (allDay) + /* [RFC 5545 3.6.1 Event Component] + For cases where a "VEVENT" calendar component + specifies a "DTSTART" property with a DATE value type but no + "DTEND" nor "DURATION" property, the event's duration is taken to + be one day. */ + Period.ofDays(1) + else + /* For cases where a "VEVENT" calendar component + specifies a "DTSTART" property with a DATE-TIME value type but no + "DTEND" property, the event ends on the same calendar date and + time of day specified by the "DTSTART" property. */ + + // Duration.ofSeconds(0) causes the calendar provider to crash + Period.ofDays(0) + } + } + + // iCalendar doesn't permit years and months, only PwWdDThHmMsS + builder .withValue(Events.DURATION, duration?.toRfc5545Duration(dtStart.date.toInstant())) + .withValue(Events.DTEND, null) + + // add RRULEs + if (event.rRules.isNotEmpty()) { + builder.withValue(Events.RRULE, event.rRules + .joinToString(AndroidTimeUtils.RECURRENCE_RULE_SEPARATOR) { it.value }) + } else + builder.withValue(Events.RRULE, null) + + if (event.rDates.isNotEmpty()) { + // ignore RDATEs when there's also an infinite RRULE [https://issuetracker.google.com/issues/216374004] + val infiniteRrule = event.rRules.any { rRule -> + rRule.recur.count == -1 && // no COUNT AND + rRule.recur.until == null // no UNTIL + } + + if (infiniteRrule) + logger.warning("Android can't handle infinite RRULE + RDATE [https://issuetracker.google.com/issues/216374004]; ignoring RDATE(s)") + else { + for (rDate in event.rDates) + AndroidTimeUtils.androidifyTimeZone(rDate) + + // Calendar provider drops DTSTART instance when using RDATE [https://code.google.com/p/android/issues/detail?id=171292] + val listWithDtStart = DateList() + listWithDtStart.add(dtStart.date) + event.rDates.addFirst(RDate(listWithDtStart)) + + builder.withValue(Events.RDATE, AndroidTimeUtils.recurrenceSetsToAndroidString(event.rDates, dtStart.date)) + } + } else + builder.withValue(Events.RDATE, null) + + if (event.exRules.isNotEmpty()) + builder.withValue(Events.EXRULE, event.exRules.joinToString(AndroidTimeUtils.RECURRENCE_RULE_SEPARATOR) { it.value }) + else + builder.withValue(Events.EXRULE, null) + + if (event.exDates.isNotEmpty()) { + for (exDate in event.exDates) + AndroidTimeUtils.androidifyTimeZone(exDate) + builder.withValue(Events.EXDATE, AndroidTimeUtils.recurrenceSetsToAndroidString(event.exDates, dtStart.date)) + } else + builder.withValue(Events.EXDATE, null) + + } else /* !recurring */ { + // dtend must be set + if (dtEnd == null) { + if (duration != null) { + // calculate dtEnd from duration + if (allDay) { + val calcDtEnd = dtStart.date.toLocalDate() + duration + dtEnd = DtEnd(calcDtEnd.toIcal4jDate()) + } else { + val zonedStartTime = (dtStart.date as DateTime).toZonedDateTime() + val calcEnd = zonedStartTime + duration + val calcDtEnd = DtEnd(calcEnd.toIcal4jDateTime()) + calcDtEnd.timeZone = dtStart.timeZone + dtEnd = calcDtEnd + } + } else { + // no dtEnd and no duration + dtEnd = if (allDay) { + /* [RFC 5545 3.6.1 Event Component] + For cases where a "VEVENT" calendar component + specifies a "DTSTART" property with a DATE value type but no + "DTEND" nor "DURATION" property, the event's duration is taken to + be one day. */ + val calcDtEnd = dtStart.date.toLocalDate() + Period.ofDays(1) + DtEnd(calcDtEnd.toIcal4jDate()) + } else + /* For cases where a "VEVENT" calendar component + specifies a "DTSTART" property with a DATE-TIME value type but no + "DTEND" property, the event ends on the same calendar date and + time of day specified by the "DTSTART" property. */ + DtEnd(dtStart.value, dtStart.timeZone) + } + } + + AndroidTimeUtils.androidifyTimeZone(dtEnd) + builder .withValue(Events.DTEND, dtEnd.date.time) + .withValue(Events.EVENT_END_TIMEZONE, AndroidTimeUtils.storageTzId(dtEnd)) + .withValue(Events.DURATION, null) + .withValue(Events.RRULE, null) + .withValue(Events.RDATE, null) + .withValue(Events.EXRULE, null) + .withValue(Events.EXDATE, null) + } + + // text fields + builder.withValue(Events.TITLE, event.summary) + .withValue(Events.EVENT_LOCATION, event.location) + .withValue(Events.DESCRIPTION, event.description) + + // color + val color = event.color + if (color != null) { + // set event color (if it's available for this account) + calendar.client.query(Colors.CONTENT_URI.asSyncAdapter(calendar.account), arrayOf(Colors.COLOR_KEY), + "${Colors.COLOR_KEY}=? AND ${Colors.COLOR_TYPE}=${Colors.TYPE_EVENT}", arrayOf(color.name), null)?.use { cursor -> + if (cursor.moveToNext()) + builder.withValue(Events.EVENT_COLOR_KEY, color.name) + else + logger.fine("Ignoring event color \"${color.name}\" (not available for this account)") + } + } else { + // reset color index and value + builder .withValue(Events.EVENT_COLOR_KEY, null) + .withValue(Events.EVENT_COLOR, null) + } + + // scheduling + val groupScheduled = event.attendees.isNotEmpty() + if (groupScheduled) { + builder .withValue(Events.HAS_ATTENDEE_DATA, 1) + .withValue(Events.ORGANIZER, event.organizer?.let { organizer -> + val uri = organizer.calAddress + val email = if (uri.scheme.equals("mailto", true)) + uri.schemeSpecificPart + else + organizer.getParameter(Parameter.EMAIL)?.value + + if (email != null) + return@let email + + logger.warning("Ignoring ORGANIZER without email address (not supported by Android)") + null + } ?: calendar.ownerAccount) + + } else /* !groupScheduled */ + builder .withValue(Events.HAS_ATTENDEE_DATA, 0) + .withValue(Events.ORGANIZER, calendar.ownerAccount) + + // Attention: don't update event with STATUS != null to STATUS = null (causes calendar provider operation to fail)! + // In this case, the whole event must be deleted and inserted again. + if (/* insert, not an update */ id == null || /* update, but we're not updating to null */ event.status != null) + builder.withValue(Events.STATUS, when (event.status) { + null /* not possible by if statement */ -> null + Status.VEVENT_CONFIRMED -> Events.STATUS_CONFIRMED + Status.VEVENT_CANCELLED -> Events.STATUS_CANCELED + else -> Events.STATUS_TENTATIVE + }) + + builder .withValue(Events.AVAILABILITY, if (event.opaque) Events.AVAILABILITY_BUSY else Events.AVAILABILITY_FREE) + .withValue(Events.ACCESS_LEVEL, when (event.classification) { + null -> Events.ACCESS_DEFAULT + Clazz.PUBLIC -> Events.ACCESS_PUBLIC + Clazz.CONFIDENTIAL -> Events.ACCESS_CONFIDENTIAL + else /* including Events.ACCESS_PRIVATE */ -> Events.ACCESS_PRIVATE + }) + } + + private fun insertReminder(batch: CalendarBatchOperation, idxEvent: Int?, alarm: VAlarm) { + val builder = CpoBuilder + .newInsert(Reminders.CONTENT_URI.asSyncAdapter(calendar.account)) + .withEventId(Reminders.EVENT_ID, idxEvent) + + val method = when (alarm.action?.value?.uppercase(Locale.ROOT)) { + Action.DISPLAY.value, + Action.AUDIO.value -> Reminders.METHOD_ALERT // will trigger an alarm on the Android device + + // Note: The calendar provider doesn't support saving specific attendees for email reminders. + Action.EMAIL.value -> Reminders.METHOD_EMAIL + + else -> Reminders.METHOD_DEFAULT // won't trigger an alarm on the Android device + } + + val minutes = ICalendar.vAlarmToMin(alarm, event, false)?.second ?: Reminders.MINUTES_DEFAULT + + builder .withValue(Reminders.METHOD, method) + .withValue(Reminders.MINUTES, minutes) + batch += builder + } + + private fun insertAttendee(batch: CalendarBatchOperation, idxEvent: Int?, attendee: Attendee, organizer: String) { + val builder = CpoBuilder + .newInsert(Attendees.CONTENT_URI.asSyncAdapter(calendar.account)) + .withEventId(Attendees.EVENT_ID, idxEvent) + + val member = attendee.calAddress + if (member.scheme.equals("mailto", true)) + // attendee identified by email + builder .withValue(Attendees.ATTENDEE_EMAIL, member.schemeSpecificPart) + else { + // attendee identified by other URI + builder .withValue(Attendees.ATTENDEE_ID_NAMESPACE, member.scheme) + .withValue(Attendees.ATTENDEE_IDENTITY, member.schemeSpecificPart) + + attendee.getParameter(Parameter.EMAIL)?.let { email -> + builder.withValue(Attendees.ATTENDEE_EMAIL, email.value) + } + } + + attendee.getParameter(Parameter.CN)?.let { cn -> + builder.withValue(Attendees.ATTENDEE_NAME, cn.value) + } + + // type/relation mapping is complex and thus outsourced to AttendeeMappings + AttendeeMappings.iCalendarToAndroid(attendee, builder, organizer) + + val status = when(attendee.getParameter(Parameter.PARTSTAT) as? PartStat) { + PartStat.ACCEPTED -> Attendees.ATTENDEE_STATUS_ACCEPTED + PartStat.DECLINED -> Attendees.ATTENDEE_STATUS_DECLINED + PartStat.TENTATIVE -> Attendees.ATTENDEE_STATUS_TENTATIVE + PartStat.DELEGATED -> Attendees.ATTENDEE_STATUS_NONE + else /* default: PartStat.NEEDS_ACTION */ -> Attendees.ATTENDEE_STATUS_INVITED + } + builder.withValue(Attendees.ATTENDEE_STATUS, status) + batch += builder + } + + private fun insertExtendedProperty(batch: CalendarBatchOperation, idxEvent: Int?, name: String, value: String) { + val builder = CpoBuilder + .newInsert(ExtendedProperties.CONTENT_URI.asSyncAdapter(calendar.account)) + .withEventId(ExtendedProperties.EVENT_ID, idxEvent) + .withValue(ExtendedProperties.NAME, name) + .withValue(ExtendedProperties.VALUE, value) + batch += builder + } + + private fun insertCategories(batch: CalendarBatchOperation, idxEvent: Int?) { + val rawCategories = event.categories // concatenate, separate by backslash + .joinToString(CATEGORIES_SEPARATOR.toString()) { category -> + // drop occurrences of CATEGORIES_SEPARATOR in category names + category.filter { it != CATEGORIES_SEPARATOR } + } + insertExtendedProperty(batch, idxEvent, EXTNAME_CATEGORIES, rawCategories) + } + + private fun insertUnknownProperty(batch: CalendarBatchOperation, idxEvent: Int?, property: Property) { + if (property.value == null) { + logger.warning("Ignoring unknown property with null value") + return + } + if (property.value.length > UnknownProperty.MAX_UNKNOWN_PROPERTY_SIZE) { + logger.warning("Ignoring unknown property with ${property.value.length} octets (too long)") + return + } + + insertExtendedProperty(batch, idxEvent, UnknownProperty.CONTENT_ITEM_TYPE, UnknownProperty.toJsonString(property)) + } + + /** + * Retain classification other than PUBLIC and PRIVATE as unknown property so + * that it can be reused when "server default" is selected. + */ + private fun retainClassification() { + event.classification?.let { + if (it != Clazz.PUBLIC && it != Clazz.PRIVATE) + event.unknownProperties += it + } + } + + + private fun CpoBuilder.withEventId(column: String, idxEvent: Int?): CpoBuilder { + if (idxEvent != null) + withValueBackReference(column, idxEvent) + else + withValue(column, requireNotNull(id)) + return this + } + +} \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessor.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessor.kt new file mode 100644 index 00000000..31aa4b27 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessor.kt @@ -0,0 +1,455 @@ +/* + * This file is part of bitfireAT/synctools which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.synctools.mapping.calendar + +import android.content.ContentValues +import android.content.Entity +import android.provider.CalendarContract.Attendees +import android.provider.CalendarContract.Events +import android.provider.CalendarContract.ExtendedProperties +import android.provider.CalendarContract.Reminders +import android.util.Patterns +import at.bitfire.ical4android.Event +import at.bitfire.ical4android.util.AndroidTimeUtils +import at.bitfire.ical4android.util.DateUtils +import at.bitfire.ical4android.util.TimeApiExtensions +import at.bitfire.ical4android.util.TimeApiExtensions.toZonedDateTime +import at.bitfire.synctools.exception.InvalidLocalResourceException +import at.bitfire.synctools.icalendar.Css3Color +import at.bitfire.synctools.storage.calendar.AndroidCalendar +import at.bitfire.synctools.storage.calendar.AndroidEvent +import at.bitfire.synctools.storage.calendar.AndroidEvent.Companion.CATEGORIES_SEPARATOR +import at.bitfire.synctools.storage.calendar.AndroidEvent.Companion.COLUMN_SEQUENCE +import at.bitfire.synctools.storage.calendar.AndroidEvent.Companion.EXTNAME_CATEGORIES +import at.bitfire.synctools.storage.calendar.AndroidEvent.Companion.EXTNAME_ICAL_UID +import at.bitfire.synctools.storage.calendar.AndroidEvent.Companion.EXTNAME_URL +import at.bitfire.synctools.storage.calendar.AndroidEvent.Companion.MUTATORS_SEPARATOR +import at.bitfire.synctools.storage.calendar.UnknownProperty +import net.fortuna.ical4j.model.Date +import net.fortuna.ical4j.model.DateList +import net.fortuna.ical4j.model.DateTime +import net.fortuna.ical4j.model.component.VAlarm +import net.fortuna.ical4j.model.parameter.Cn +import net.fortuna.ical4j.model.parameter.Email +import net.fortuna.ical4j.model.parameter.PartStat +import net.fortuna.ical4j.model.parameter.Rsvp +import net.fortuna.ical4j.model.parameter.Value +import net.fortuna.ical4j.model.property.Action +import net.fortuna.ical4j.model.property.Attendee +import net.fortuna.ical4j.model.property.Clazz +import net.fortuna.ical4j.model.property.Description +import net.fortuna.ical4j.model.property.DtEnd +import net.fortuna.ical4j.model.property.DtStart +import net.fortuna.ical4j.model.property.ExDate +import net.fortuna.ical4j.model.property.ExRule +import net.fortuna.ical4j.model.property.Organizer +import net.fortuna.ical4j.model.property.RDate +import net.fortuna.ical4j.model.property.RRule +import net.fortuna.ical4j.model.property.RecurrenceId +import net.fortuna.ical4j.model.property.Status +import net.fortuna.ical4j.model.property.Summary +import net.fortuna.ical4j.util.TimeZones +import java.net.URI +import java.net.URISyntaxException +import java.time.Duration +import java.time.Instant +import java.time.Period +import java.time.ZoneOffset +import java.time.ZonedDateTime +import java.util.logging.Level +import java.util.logging.Logger + +/** + * Legacy mapper from Android event main + data rows to an [Event] + * (former "populate..." methods). + * + * Important: To use recurrence exceptions, you MUST set _SYNC_ID and ORIGINAL_SYNC_ID + * in populateEvent() / buildEvent. Setting _ID and ORIGINAL_ID is not sufficient. + */ +class LegacyAndroidEventProcessor( + private val calendar: AndroidCalendar, + private val id: Long, + private val entity: Entity +) { + + private val logger + get() = Logger.getLogger(javaClass.name) + + fun populate(to: Event) { + // calculate some scheduling properties + val hasAttendees = entity.subValues.any { it.uri == Attendees.CONTENT_URI } + + // main row + populateEvent(entity.entityValues, groupScheduled = hasAttendees, to = to) + + // data rows + for (subValue in entity.subValues) { + val subValues = subValue.values + when (subValue.uri) { + Attendees.CONTENT_URI -> populateAttendee(subValues, to = to) + Reminders.CONTENT_URI -> populateReminder(subValues, to = to) + ExtendedProperties.CONTENT_URI -> populateExtended(subValues, to = to) + } + } + + // exceptions + populateExceptions(to = to) + + // post-processing + useRetainedClassification(to) + } + + /** + * Reads event data from the calendar provider = maps the [entity] values to + * the [event] data object. + * + * @param row values of an [Events] row, as returned by the calendar provider + */ + private fun populateEvent(row: ContentValues, groupScheduled: Boolean, to: Event) { + logger.log(Level.FINE, "Read event entity from calender provider", row) + + row.getAsString(Events.MUTATORS)?.let { strPackages -> + val packages = strPackages.split(MUTATORS_SEPARATOR).toSet() + to.userAgents.addAll(packages) + } + + val allDay = (row.getAsInteger(Events.ALL_DAY) ?: 0) != 0 + val tsStart = row.getAsLong(Events.DTSTART) ?: throw InvalidLocalResourceException("Found event without DTSTART") + + var tsEnd = row.getAsLong(Events.DTEND) + var duration = // only use DURATION of DTEND is not defined + if (tsEnd == null) + row.getAsString(Events.DURATION)?.let { AndroidTimeUtils.parseDuration(it) } + else + null + + if (allDay) { + to.dtStart = DtStart(Date(tsStart)) + + // Android events MUST have duration or dtend [https://developer.android.com/reference/android/provider/CalendarContract.Events#operations]. + // Assume 1 day if missing (should never occur, but occurs). + if (tsEnd == null && duration == null) + duration = Duration.ofDays(1) + + if (duration != null) { + // Some servers have problems with DURATION, so we always generate DTEND. + val startDate = ZonedDateTime.ofInstant(Instant.ofEpochMilli(tsStart), ZoneOffset.UTC).toLocalDate() + if (duration is Duration) + duration = Period.ofDays(duration.toDays().toInt()) + tsEnd = (startDate + duration).toEpochDay() * TimeApiExtensions.MILLIS_PER_DAY + duration = null + } + + if (tsEnd != null) { + when { + tsEnd < tsStart -> + logger.warning("dtEnd $tsEnd (allDay) < dtStart $tsStart (allDay), ignoring") + + tsEnd == tsStart -> + logger.fine("dtEnd $tsEnd (allDay) = dtStart, won't generate DTEND property") + + else /* tsEnd > tsStart */ -> + to.dtEnd = DtEnd(Date(tsEnd)) + } + } + + } else /* !allDay */ { + // use DATE-TIME values + + // check time zone ID (calendar apps may insert no or an invalid ID) + val startTzId = DateUtils.findAndroidTimezoneID(row.getAsString(Events.EVENT_TIMEZONE)) + val startTz = DateUtils.ical4jTimeZone(startTzId) + val dtStartDateTime = DateTime(tsStart).apply { + if (startTz != null) { // null if there was not ical4j time zone for startTzId, which should not happen, but technically may happen + if (TimeZones.isUtc(startTz)) + isUtc = true + else + timeZone = startTz + } + } + to.dtStart = DtStart(dtStartDateTime) + + // Android events MUST have duration or dtend [https://developer.android.com/reference/android/provider/CalendarContract.Events#operations]. + // Assume 1 hour if missing (should never occur, but occurs). + if (tsEnd == null && duration == null) + duration = Duration.ofHours(1) + + if (duration != null) { + // Some servers have problems with DURATION, so we always generate DTEND. + val zonedStart = dtStartDateTime.toZonedDateTime() + tsEnd = (zonedStart + duration).toInstant().toEpochMilli() + duration = null + } + + if (tsEnd != null) { + if (tsEnd < tsStart) + logger.warning("dtEnd $tsEnd < dtStart $tsStart, ignoring") + /*else if (tsEnd == tsStart) // iCloud sends 404 when it receives an iCalendar with DTSTART but without DTEND + logger.fine("dtEnd $tsEnd == dtStart, won't generate DTEND property")*/ + else /* tsEnd > tsStart */ { + val endTz = row.getAsString(Events.EVENT_END_TIMEZONE)?.let { tzId -> + DateUtils.ical4jTimeZone(tzId) + } ?: startTz + to.dtEnd = DtEnd(DateTime(tsEnd).apply { + if (endTz != null) { + if (TimeZones.isUtc(endTz)) + isUtc = true + else + timeZone = endTz + } + }) + } + } + + } + + // recurrence + try { + row.getAsString(Events.RRULE)?.let { rulesStr -> + for (rule in rulesStr.split(AndroidTimeUtils.RECURRENCE_RULE_SEPARATOR)) + to.rRules += RRule(rule) + } + row.getAsString(Events.RDATE)?.let { datesStr -> + val rDate = AndroidTimeUtils.androidStringToRecurrenceSet(datesStr, allDay, tsStart) { RDate(it) } + to.rDates += rDate + } + + row.getAsString(Events.EXRULE)?.let { rulesStr -> + for (rule in rulesStr.split(AndroidTimeUtils.RECURRENCE_RULE_SEPARATOR)) + to.exRules += ExRule(null, rule) + } + row.getAsString(Events.EXDATE)?.let { datesStr -> + val exDate = AndroidTimeUtils.androidStringToRecurrenceSet(datesStr, allDay) { ExDate(it) } + to.exDates += exDate + } + } catch (e: Exception) { + logger.log(Level.WARNING, "Couldn't parse recurrence rules, ignoring", e) + } + + to.uid = row.getAsString(Events.UID_2445) + to.sequence = row.getAsInteger(COLUMN_SEQUENCE) + to.isOrganizer = row.getAsBoolean(Events.IS_ORGANIZER) + + to.summary = row.getAsString(Events.TITLE) + to.location = row.getAsString(Events.EVENT_LOCATION) + to.description = row.getAsString(Events.DESCRIPTION) + + // color can be specified as RGB value and/or as index key (CSS3 color of AndroidCalendar) + to.color = + row.getAsString(Events.EVENT_COLOR_KEY)?.let { name -> // try color key first + try { + Css3Color.valueOf(name) + } catch (_: IllegalArgumentException) { + logger.warning("Ignoring unknown color name \"$name\"") + null + } + } ?: + row.getAsInteger(Events.EVENT_COLOR)?.let { color -> // otherwise, try to find the color name from the value + Css3Color.entries.firstOrNull { it.argb == color } + } + + // status + when (row.getAsInteger(Events.STATUS)) { + Events.STATUS_CONFIRMED -> to.status = Status.VEVENT_CONFIRMED + Events.STATUS_TENTATIVE -> to.status = Status.VEVENT_TENTATIVE + Events.STATUS_CANCELED -> to.status = Status.VEVENT_CANCELLED + } + + // availability + to.opaque = row.getAsInteger(Events.AVAILABILITY) != Events.AVAILABILITY_FREE + + // scheduling + if (groupScheduled) { + // ORGANIZER must only be set for group-scheduled events (= events with attendees) + if (row.containsKey(Events.ORGANIZER)) + try { + to.organizer = Organizer(URI("mailto", row.getAsString(Events.ORGANIZER), null)) + } catch (e: URISyntaxException) { + logger.log(Level.WARNING, "Error when creating ORGANIZER mailto URI, ignoring", e) + } + } + + // classification + when (row.getAsInteger(Events.ACCESS_LEVEL)) { + Events.ACCESS_PUBLIC -> to.classification = Clazz.PUBLIC + Events.ACCESS_PRIVATE -> to.classification = Clazz.PRIVATE + Events.ACCESS_CONFIDENTIAL -> to.classification = Clazz.CONFIDENTIAL + } + + // exceptions from recurring events + row.getAsLong(Events.ORIGINAL_INSTANCE_TIME)?.let { originalInstanceTime -> + val originalAllDay = (row.getAsInteger(Events.ORIGINAL_ALL_DAY) ?: 0) != 0 + val originalDate = + if (originalAllDay) + Date(originalInstanceTime) + else + DateTime(originalInstanceTime) + if (originalDate is DateTime) { + to.dtStart?.let { dtStart -> + if (dtStart.isUtc) + originalDate.isUtc = true + else if (dtStart.timeZone != null) + originalDate.timeZone = dtStart.timeZone + } + } + to.recurrenceId = RecurrenceId(originalDate) + } + } + + private fun populateAttendee(row: ContentValues, to: Event) { + logger.log(Level.FINE, "Read event attendee from calender provider", row) + + try { + val attendee: Attendee + val email = row.getAsString(Attendees.ATTENDEE_EMAIL) + val idNS = row.getAsString(Attendees.ATTENDEE_ID_NAMESPACE) + val id = row.getAsString(Attendees.ATTENDEE_IDENTITY) + + if (idNS != null || id != null) { + // attendee identified by namespace and ID + attendee = Attendee(URI(idNS, id, null)) + email?.let { attendee.parameters.add(Email(it)) } + } else + // attendee identified by email address + attendee = Attendee(URI("mailto", email, null)) + val params = attendee.parameters + + // always add RSVP (offer attendees to accept/decline) + params.add(Rsvp.TRUE) + + row.getAsString(Attendees.ATTENDEE_NAME)?.let { cn -> params.add(Cn(cn)) } + + // type/relation mapping is complex and thus outsourced to AttendeeMappings + AttendeeMappings.androidToICalendar(row, attendee) + + // status + when (row.getAsInteger(Attendees.ATTENDEE_STATUS)) { + Attendees.ATTENDEE_STATUS_INVITED -> params.add(PartStat.NEEDS_ACTION) + Attendees.ATTENDEE_STATUS_ACCEPTED -> params.add(PartStat.ACCEPTED) + Attendees.ATTENDEE_STATUS_DECLINED -> params.add(PartStat.DECLINED) + Attendees.ATTENDEE_STATUS_TENTATIVE -> params.add(PartStat.TENTATIVE) + Attendees.ATTENDEE_STATUS_NONE -> { /* no information, don't add PARTSTAT */ } + } + + to.attendees.add(attendee) + } catch (e: URISyntaxException) { + logger.log(Level.WARNING, "Couldn't parse attendee information, ignoring", e) + } + } + + private fun populateReminder(row: ContentValues, to: Event) { + logger.log(Level.FINE, "Read event reminder from calender provider", row) + + val alarm = VAlarm(Duration.ofMinutes(-row.getAsLong(Reminders.MINUTES))) + + val props = alarm.properties + 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(to.summary) + props += Description(to.description ?: to.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 { + logger.warning("Account name is not an email address; changing EMAIL reminder to DISPLAY") + props += Action.DISPLAY + props += Description(to.summary) + } + } + + // default: set ACTION:DISPLAY (requires DESCRIPTION) + else -> { + props += Action.DISPLAY + props += Description(to.summary) + } + } + to.alarms += alarm + } + + private fun populateExtended(row: ContentValues, to: Event) { + val name = row.getAsString(ExtendedProperties.NAME) + val rawValue = row.getAsString(ExtendedProperties.VALUE) + logger.log(Level.FINE, "Read extended property from calender provider", arrayOf(name, rawValue)) + + try { + when (name) { + EXTNAME_CATEGORIES -> + to.categories += rawValue.split(CATEGORIES_SEPARATOR) + + EXTNAME_URL -> + try { + to.url = URI(rawValue) + } catch(_: URISyntaxException) { + logger.warning("Won't process invalid local URL: $rawValue") + } + + EXTNAME_ICAL_UID -> + // only consider iCalUid when there's no uid + if (to.uid == null) + to.uid = rawValue + + UnknownProperty.CONTENT_ITEM_TYPE -> + to.unknownProperties += UnknownProperty.fromJsonString(rawValue) + } + } catch (e: Exception) { + logger.log(Level.WARNING, "Couldn't parse extended property", e) + } + } + + private fun populateExceptions(to: Event) { + calendar.iterateEventEntities(null, Events.ORIGINAL_ID + "=?", arrayOf(id.toString())) { exceptionEntity -> + val exception = AndroidEvent(calendar, exceptionEntity) + val exceptionEvent = exception.event + val recurrenceId = exceptionEvent.recurrenceId!! + + // generate EXDATE instead of RECURRENCE-ID exceptions for cancelled instances + if (exceptionEvent.status == Status.VEVENT_CANCELLED) { + val list = DateList( + if (DateUtils.isDate(recurrenceId)) Value.DATE else Value.DATE_TIME, + recurrenceId.timeZone + ) + list.add(recurrenceId.date) + to.exDates += ExDate(list).apply { + if (DateUtils.isDateTime(recurrenceId)) { + if (recurrenceId.isUtc) + setUtc(true) + else + timeZone = recurrenceId.timeZone + } + } + + } else /* exceptionEvent.status != Status.VEVENT_CANCELLED */ { + // make sure that all components have the same ORGANIZER [RFC 6638 3.1] + exceptionEvent.organizer = to.organizer + + // add exception to list of exceptions + to.exceptions += exceptionEvent + } + } + } + + private fun useRetainedClassification(event: Event) { + var retainedClazz: Clazz? = null + val it = event.unknownProperties.iterator() + while (it.hasNext()) { + val prop = it.next() + if (prop is Clazz) { + retainedClazz = prop + it.remove() + } + } + + if (event.classification == null) + // no classification, use retained one if possible + event.classification = retainedClazz + } + +} \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/synctools/storage/calendar/AndroidCalendar.kt b/lib/src/main/kotlin/at/bitfire/synctools/storage/calendar/AndroidCalendar.kt index 4187d085..51831475 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/storage/calendar/AndroidCalendar.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/storage/calendar/AndroidCalendar.kt @@ -8,25 +8,36 @@ package at.bitfire.synctools.storage.calendar import android.content.ContentUris import android.content.ContentValues +import android.content.Entity import android.os.RemoteException import android.provider.CalendarContract +import android.provider.CalendarContract.Attendees import android.provider.CalendarContract.Calendars import android.provider.CalendarContract.Events -import at.bitfire.ical4android.AndroidEvent +import android.provider.CalendarContract.EventsEntity +import android.provider.CalendarContract.ExtendedProperties +import android.provider.CalendarContract.Instances +import android.provider.CalendarContract.Reminders +import at.bitfire.ical4android.Event import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter +import at.bitfire.synctools.mapping.calendar.LegacyAndroidEventBuilder +import at.bitfire.synctools.storage.BatchOperation.CpoBuilder import at.bitfire.synctools.storage.LocalStorageException +import at.bitfire.synctools.storage.calendar.AndroidEvent.Companion.EXTNAME_CATEGORIES +import at.bitfire.synctools.storage.calendar.AndroidEvent.Companion.EXTNAME_ICAL_UID +import at.bitfire.synctools.storage.calendar.AndroidEvent.Companion.EXTNAME_URL import at.bitfire.synctools.storage.toContentValues import java.util.LinkedList /** - * Represents a locally stored calendar, containing [at.bitfire.ical4android.AndroidEvent]s (whose data objects are [at.bitfire.ical4android.Event]s). - * Communicates with the Android Contacts Provider which uses an SQLite - * database to store the events. + * Represents a locally stored calendar, containing [AndroidEvent]s (whose data objects are [Event]s). * - * @param client calendar provider - * @param values content values as read from the calendar provider; [android.provider.BaseColumns._ID] must be set + * Manages locally stored events (and other data like instances of events) in the Android calendar provider. * - * @throws IllegalArgumentException when [android.provider.BaseColumns._ID] is not set + * @param client calendar provider + * @param values content values as read from the calendar provider; [Calendars._ID] must be set + * + * @throws IllegalArgumentException when [Calendars._ID] is not set */ class AndroidCalendar( val provider: AndroidCalendarProvider, @@ -60,6 +71,41 @@ class AndroidCalendar( // CRUD AndroidEvent + /** + * @return ID of the created event in the provider + */ + fun createEventFromDataObject( + event: Event, + syncId: String? = null, + eTag: String? = null, + scheduleTag: String? = null, + flags: Int = 0 + ): Long { + val batch = CalendarBatchOperation(client) + + val builder = LegacyAndroidEventBuilder( + calendar = this, + event = event, + id = null, + syncId = syncId, + eTag = eTag, + scheduleTag = scheduleTag, + flags = flags + ) + val idxEvent = builder.addOrUpdateRows(event, batch) ?: throw AssertionError("Expected Events._ID backref") + batch.commit() + + val resultUri = batch.getResult(idxEvent)?.uri + ?: throw LocalStorageException("Empty result from content provider when adding event") + + return ContentUris.parseId(resultUri) + } + + fun createAndGetEventFromDataObject(event: Event): AndroidEvent { + val id = createEventFromDataObject(event) + return getEvent(id) ?: throw LocalStorageException("Created event not available") + } + /** * Queries events from this calendar. * @@ -74,9 +120,8 @@ class AndroidCalendar( val events = LinkedList() try { val (protectedWhere, protectedWhereArgs) = whereWithCalendarId(where, whereArgs) - client.query(eventsUri, null, protectedWhere, protectedWhereArgs, null)?.use { cursor -> - while (cursor.moveToNext()) - events += AndroidEvent(this, cursor.toContentValues()) + iterateEventEntities(null, protectedWhere, protectedWhereArgs) { event -> + events += AndroidEvent(this, event) } } catch (e: RemoteException) { throw LocalStorageException("Couldn't query events", e) @@ -91,10 +136,33 @@ class AndroidCalendar( * @return event (or `null` if not found) */ fun getEvent(id: Long): AndroidEvent? { - val values = getEventValues(id) ?: return null + val values = getEventEntity(id) ?: return null return AndroidEvent(this, values) } + /** + * Gets the event row + data rows of a specific event, identified by its ID, from this calendar. + * + * @param id event ID + * @return event row + data rows (or `null` if not found) + */ + fun getEventEntity(id: Long, projection: Array? = null, where: String? = null, whereArgs: Array? = null): Entity? { + try { + client.query(eventUri(id), projection, where, whereArgs, null)?.use { cursor -> + val entityIterator = EventsEntity.newEntityIterator(cursor, client) + try { + if (entityIterator.hasNext()) + return entityIterator.next() + } finally { + entityIterator.close() + } + } + } catch (e: RemoteException) { + throw LocalStorageException("Couldn't query event entity", e) + } + return null + } + /** * Gets the main event row of a specific event, identified by its ID, from this calendar. * @@ -113,6 +181,32 @@ class AndroidCalendar( return null } + /** + * Iterates event entites from this calendar. + * + * Adds a WHERE clause that restricts the query to [CalendarContract.EventsColumns.CALENDAR_ID] = [id]. + * + * @param projection requested fields + * @param where selection + * @param whereArgs arguments for selection + */ + fun iterateEventEntities(projection: Array?, where: String?, whereArgs: Array?, body: (Entity) -> Unit) { + try { + val (protectedWhere, protectedWhereArgs) = whereWithCalendarId(where, whereArgs) + client.query(eventsUri, projection, protectedWhere, protectedWhereArgs, null)?.use { cursor -> + val entityIterator = EventsEntity.newEntityIterator(cursor, client) + try { + while (entityIterator.hasNext()) + body(entityIterator.next()) + } finally { + entityIterator.close() + } + } + } catch (e: RemoteException) { + throw LocalStorageException("Couldn't iterate events", e) + } + } + /** * Iterates events from this calendar. * @@ -137,6 +231,100 @@ class AndroidCalendar( } } + fun updateEventFromDataObject( + event: Event, + id: Long, + syncId: String? = null, + eTag: String? = null, + scheduleTag: String? = null, + flags: Int = 0 + ): Long { + // There are cases where the event cannot be updated, but must be completely re-created. + // Case 1: Events.STATUS shall be updated from a non-null value (like STATUS_CONFIRMED) to null. + var rebuild = false + if (event.status == null) + getEventValues(id, arrayOf(Events.STATUS))?.let { values -> + if (values.getAsInteger(Events.STATUS) != null) + rebuild = true + } + + if (rebuild) { + // delete whole event, insert updated event, return new ID + deleteEvent(id) + return createEventFromDataObject( + event = event, + syncId = syncId, + eTag = eTag, + scheduleTag = scheduleTag, + flags = flags + ) + + } else { + // update event + + // remove associated rows which are added later again + val batch = CalendarBatchOperation(client) + + deleteExceptions(batch) + batch += CpoBuilder + .newDelete(Reminders.CONTENT_URI.asSyncAdapter(account)) + .withSelection("${Reminders.EVENT_ID}=?", arrayOf(id.toString())) + batch += CpoBuilder + .newDelete(Attendees.CONTENT_URI.asSyncAdapter(account)) + .withSelection("${Attendees.EVENT_ID}=?", arrayOf(id.toString())) + batch += CpoBuilder + .newDelete(ExtendedProperties.CONTENT_URI.asSyncAdapter(account)) + .withSelection( + "${ExtendedProperties.EVENT_ID}=? AND ${ExtendedProperties.NAME} IN (?,?,?,?)", + arrayOf( + id.toString(), + EXTNAME_CATEGORIES, + EXTNAME_ICAL_UID, // UID is stored in UID_2445, don't leave iCalUid rows in events that we have written + EXTNAME_URL, + UnknownProperty.CONTENT_ITEM_TYPE + ) + ) + + // update main row / add data rows again + val builder = LegacyAndroidEventBuilder( + calendar = this, + event = event, + id = id, + syncId = syncId, + eTag = eTag, + scheduleTag = scheduleTag, + flags = flags + ) + builder.addOrUpdateRows(event, batch) + batch.commit() + + return id // return unchanged ID + } + } + + private fun deleteExceptions(batch: CalendarBatchOperation) { + val existingId = requireNotNull(id) + batch += CpoBuilder + .newDelete(Events.CONTENT_URI.asSyncAdapter(account)) + .withSelection("${Events.ORIGINAL_ID}=?", arrayOf(existingId.toString())) + } + + /** + * Updates event in this calendar. + * + * @param id ID of the event to update + * @param values values to update + * + * @return number of updated rows + * @throws LocalStorageException when the content provider returns an error + */ + fun updateEvent(id: Long, values: ContentValues): Int = + try { + client.update(eventUri(id), values, null, null) + } catch (e: RemoteException) { + throw LocalStorageException("Couldn't update events", e) + } + /** * Updates events in this calendar. * @@ -154,6 +342,14 @@ class AndroidCalendar( throw LocalStorageException("Couldn't update events", e) } + fun deleteEvent(id: Long): Int { + try { + return client.delete(eventUri(id), null, null) + } catch (e: RemoteException) { + throw LocalStorageException("Couldn't delete event", e) + } + } + // shortcuts to upper level @@ -177,6 +373,74 @@ class AndroidCalendar( } + // event instances + + /** + * Finds the amount of direct instances this event has (without exceptions); used by [numInstances] + * to find the number of instances of exceptions. + * + * The number of returned instances may vary with the Android version. + * + * @return number of direct event instances (not counting instances of exceptions); *null* if + * the number can't be determined or if the event has no last date (recurring event without last instance) + */ + fun numDirectInstances(eventId: Long): Int? { + // query event to get first and last instance + var first: Long? = null + var last: Long? = null + getEventValues(eventId, arrayOf(Events.DTSTART, Events.LAST_DATE))?.let { event -> + first = event.getAsLong(Events.DTSTART) + last = event.getAsLong(Events.LAST_DATE) + } + // if this event doesn't have a last occurrence, it's endless and always has instances + if (first == null || last == null) + return null + + /* Query instances of the event within a given start and end. We can't use Long.MIN_VALUE and + Long.MAX_VALUE because the calendar provider generates the instances on the fly and doesn't accept those + values. So we use the first/last actual occurrence of the event (as calculated by the provider). */ + val instancesUri = Instances.CONTENT_URI.asSyncAdapter(account).buildUpon() + .appendPath(first.toString()) // begin timestamp + .appendPath(last.toString()) // end timestamp + .build() + + var numInstances = 0 + client.query( + instancesUri, null, + "${Instances.EVENT_ID}=?", arrayOf(eventId.toString()), + null + )?.use { cursor -> + numInstances += cursor.count + } + return numInstances + } + + /** + * Finds the total number of instances this event has (including instances of exceptions). + * + * The number of returned instances may vary with the Android version. + * + * @return number of direct event instances (not counting instances of exceptions); *null* if + * the number can't be determined or if the event has no last date (recurring event without last instance) + */ + fun numInstances(eventId: Long): Int? { + // num instances of the main event + var numInstances: Int? = numDirectInstances(eventId) ?: return null + + // add the number of instances of every main event's exception + iterateEvents(arrayOf(Events._ID), "${Events.ORIGINAL_ID}=?", arrayOf(eventId.toString())) { event -> + val exceptionEventId = event.getAsLong(Events._ID) + val exceptionInstances = numDirectInstances(exceptionEventId) + + numInstances = if (exceptionInstances == null) + null // number of instances of exception can't be determined; so the total number of instances is also unclear + else + numInstances?.plus(exceptionInstances) + } + return numInstances + } + + // helpers val account @@ -185,6 +449,9 @@ class AndroidCalendar( val client get() = provider.client + val eventEntitiesUri + get() = EventsEntity.CONTENT_URI.asSyncAdapter(account) + val eventsUri get() = Events.CONTENT_URI.asSyncAdapter(account) diff --git a/lib/src/main/kotlin/at/bitfire/synctools/storage/calendar/AndroidEvent.kt b/lib/src/main/kotlin/at/bitfire/synctools/storage/calendar/AndroidEvent.kt new file mode 100644 index 00000000..818eef91 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/storage/calendar/AndroidEvent.kt @@ -0,0 +1,155 @@ +/* + * This file is part of bitfireAT/synctools which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.synctools.storage.calendar + +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.ContentResolver +import android.content.ContentUris +import android.content.ContentValues +import android.content.Entity +import android.provider.CalendarContract +import android.provider.CalendarContract.Events +import androidx.core.content.contentValuesOf +import at.bitfire.ical4android.Event +import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter +import at.bitfire.synctools.mapping.calendar.LegacyAndroidEventProcessor +import at.bitfire.synctools.storage.calendar.AndroidEvent.Companion.CATEGORIES_SEPARATOR +import java.util.logging.Logger + +/** + * Represents a locally stored event with an associated [at.bitfire.ical4android.Event] data object. + * + * @param calendar calendar that manages this event + * @param entity event row and associated data rows as read from the calendar provider; [Events._ID] must be set + * + * @throws IllegalArgumentException when [Events._ID] is not set + */ +class AndroidEvent( + val calendar: AndroidCalendar, + val entity: Entity // TODO removeBlank +) { + + private val mainValues + get() = entity.entityValues + + private val logger: Logger + get() = Logger.getLogger(javaClass.name) + + /** see [android.provider.BaseColumns._ID] */ + val id: Long = mainValues.getAsLong(Events._ID) + ?: throw IllegalArgumentException("${Events._ID} must be set") + + val syncId: String? + get() = mainValues.getAsString(Events._SYNC_ID) + + val eTag: String? + get() = mainValues.getAsString(COLUMN_ETAG) + + val scheduleTag: String? + get() = mainValues.getAsString(COLUMN_SCHEDULE_TAG) + + val flags: Int + get() = mainValues.getAsInteger(COLUMN_FLAGS) ?: 0 + + /** + * Returns the full event data, either from [event] or, if [event] is null, by reading event + * number [id] from the Android calendar storage. + * + * @throws IllegalArgumentException if event has not been saved yet + * @throws java.io.FileNotFoundException if there's no event with [id] in the calendar storage + * @throws android.os.RemoteException on calendar provider errors + */ + val event: Event by lazy { + Event().also { newEvent -> + val processor = LegacyAndroidEventProcessor(calendar, id, entity) + processor.populate(to = newEvent) + } + } + + + // shortcuts to upper level + + fun update(values: ContentValues) = calendar.updateEvent(id, values) + + fun delete() = calendar.deleteEvent(id) + + + // TODO: override fun toString(): String = "AndroidEvent(calendar=$calendar, id=$id, event=$event)" + + + companion object { + + const val MUTATORS_SEPARATOR = ',' + + /** + * Custom sync column to store the last known ETag of an event. + */ + const val COLUMN_ETAG = Events.SYNC_DATA1 + + /** + * Custom sync column to store sync flags of an event. + */ + const val COLUMN_FLAGS = Events.SYNC_DATA2 + + /** + * Custom sync column to store the SEQUENCE of an event. + */ + const val COLUMN_SEQUENCE = Events.SYNC_DATA3 + + /** + * Custom sync column to store the Schedule-Tag of an event. + */ + const val COLUMN_SCHEDULE_TAG = Events.SYNC_DATA4 + + /** + * VEVENT CATEGORIES are stored as an extended property with this [CalendarContract.ExtendedProperties.NAME]. + * + * The [CalendarContract.ExtendedProperties.VALUE] format is the same as used by the AOSP Exchange ActiveSync adapter: + * the category values are stored as list, separated by [CATEGORIES_SEPARATOR]. (If a category + * value contains [CATEGORIES_SEPARATOR], [CATEGORIES_SEPARATOR] will be dropped.) + * + * Example: `Cat1\Cat2` + */ + const val EXTNAME_CATEGORIES = "categories" + const val CATEGORIES_SEPARATOR = '\\' + + /** + * Google Calendar uses an extended property called `iCalUid` for storing the event's UID, instead of the + * standard [CalendarContract.EventsColumns.UID_2445]. + * + * @see GitHub Issue + */ + const val EXTNAME_ICAL_UID = "iCalUid" + + /** + * VEVENT URL is stored as an extended property with this [CalendarContract.ExtendedProperties.NAME]. + * The URL is directly put into [CalendarContract.ExtendedProperties.VALUE]. + */ + const val EXTNAME_URL = ContentResolver.CURSOR_ITEM_BASE_TYPE + "/vnd.ical4android.url" + + + // helpers + + /** + * Marks the event as deleted + * @param eventID + */ + fun markAsDeleted(provider: ContentProviderClient, account: Account, eventID: Long) { + provider.update( + ContentUris.withAppendedId( + Events.CONTENT_URI, + eventID + ).asSyncAdapter(account), + contentValuesOf(Events.DELETED to 1), + null, null + ) + } + + } + +} \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/UnknownProperty.kt b/lib/src/main/kotlin/at/bitfire/synctools/storage/calendar/UnknownProperty.kt similarity index 98% rename from lib/src/main/kotlin/at/bitfire/ical4android/UnknownProperty.kt rename to lib/src/main/kotlin/at/bitfire/synctools/storage/calendar/UnknownProperty.kt index 504cd92b..6ffd992a 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/UnknownProperty.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/storage/calendar/UnknownProperty.kt @@ -4,7 +4,7 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -package at.bitfire.ical4android +package at.bitfire.synctools.storage.calendar import android.content.ContentResolver import net.fortuna.ical4j.data.DefaultParameterFactorySupplier diff --git a/lib/src/main/kotlin/at/bitfire/synctools/test/InitCalendarProviderRule.kt b/lib/src/main/kotlin/at/bitfire/synctools/test/InitCalendarProviderRule.kt index f4b29928..08b7d01e 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/test/InitCalendarProviderRule.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/test/InitCalendarProviderRule.kt @@ -15,7 +15,6 @@ import android.provider.CalendarContract.Calendars import androidx.core.content.contentValuesOf import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule -import at.bitfire.ical4android.AndroidEvent import at.bitfire.ical4android.Event import at.bitfire.synctools.storage.calendar.AndroidCalendar import at.bitfire.synctools.storage.calendar.AndroidCalendarProvider @@ -83,23 +82,19 @@ class InitCalendarProviderRule private constructor() : ExternalResource() { try { // single event init - val normalEvent = Event().apply { + val normalLocalEvent = calendar.createEventFromDataObject(Event().apply { dtStart = DtStart("20220120T010203Z") summary = "Event with 1 instance" - } - val normalLocalEvent = AndroidEvent(calendar, normalEvent, null, null, null, 0) - normalLocalEvent.add() - AndroidEvent.numInstances(provider, account, normalLocalEvent.id!!) + }) + calendar.numInstances(normalLocalEvent) // recurring event init - val recurringEvent = Event().apply { + val recurringEvent = calendar.createEventFromDataObject(Event().apply { dtStart = DtStart("20220120T010203Z") summary = "Event over 22 years" rRules.add(RRule("FREQ=YEARLY;UNTIL=20740119T010203Z")) // year needs to be >2074 (not supported by Android <11 Calendar Storage) - } - val localRecurringEvent = AndroidEvent(calendar, recurringEvent, null, null, null, 0) - localRecurringEvent.add() - AndroidEvent.numInstances(provider, account, localRecurringEvent.id!!) + }) + calendar.numInstances(recurringEvent) } finally { calendar.delete() } diff --git a/lib/src/main/resources/ical4j.properties b/lib/src/main/resources/ical4j.properties index edc3d429..ad8d060c 100644 --- a/lib/src/main/resources/ical4j.properties +++ b/lib/src/main/resources/ical4j.properties @@ -1,6 +1,6 @@ net.fortuna.ical4j.timezone.cache.impl=net.fortuna.ical4j.util.MapTimeZoneCache net.fortuna.ical4j.timezone.offset.negative_dst_supported=true -net.fortuna.ical4j.timezone.registry=at.bitfire.ical4android.AndroidCompatTimeZoneRegistry$Factory +net.fortuna.ical4j.timezone.registry=at.bitfire.synctools.icalendar.AndroidCompatTimeZoneRegistry$Factory net.fortuna.ical4j.timezone.update.enabled=false ical4j.unfolding.relaxed=true ical4j.parsing.relaxed=true diff --git a/lib/src/androidTest/kotlin/at/bitfire/ical4android/AndroidCompatTimeZoneRegistryTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/icalendar/AndroidCompatTimeZoneRegistryTest.kt similarity index 76% rename from lib/src/androidTest/kotlin/at/bitfire/ical4android/AndroidCompatTimeZoneRegistryTest.kt rename to lib/src/test/kotlin/at/bitfire/synctools/icalendar/AndroidCompatTimeZoneRegistryTest.kt index 43a763ba..eb960903 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/ical4android/AndroidCompatTimeZoneRegistryTest.kt +++ b/lib/src/test/kotlin/at/bitfire/synctools/icalendar/AndroidCompatTimeZoneRegistryTest.kt @@ -4,14 +4,12 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -package at.bitfire.ical4android +package at.bitfire.synctools.icalendar import net.fortuna.ical4j.model.DefaultTimeZoneRegistryFactory import net.fortuna.ical4j.model.TimeZone import net.fortuna.ical4j.model.TimeZoneRegistry -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull +import org.junit.Assert import org.junit.Assume import org.junit.Before import org.junit.Test @@ -40,7 +38,7 @@ class AndroidCompatTimeZoneRegistryTest { @Test fun getTimeZone_Existing() { - assertEquals( + Assert.assertEquals( ical4jRegistry.getTimeZone("Europe/Vienna"), registry.getTimeZone("Europe/Vienna") ) @@ -48,23 +46,23 @@ class AndroidCompatTimeZoneRegistryTest { @Test fun getTimeZone_Existing_ButNotInIcal4j() { - val reg = AndroidCompatTimeZoneRegistry(object: TimeZoneRegistry { + val reg = AndroidCompatTimeZoneRegistry(object : TimeZoneRegistry { override fun register(timezone: TimeZone?) = throw NotImplementedError() override fun register(timezone: TimeZone?, update: Boolean) = throw NotImplementedError() override fun clear() = throw NotImplementedError() override fun getTimeZone(id: String?) = null }) - assertNull(reg.getTimeZone("Europe/Berlin")) + Assert.assertNull(reg.getTimeZone("Europe/Berlin")) } @Test fun getTimeZone_Existing_Kiev() { Assume.assumeFalse(systemKnowsKyiv) val tz = registry.getTimeZone("Europe/Kiev") - assertFalse(tz === ical4jRegistry.getTimeZone("Europe/Kiev")) // we have made a copy - assertEquals("Europe/Kiev", tz?.id) - assertEquals("Europe/Kiev", tz?.vTimeZone?.timeZoneId?.value) + Assert.assertFalse(tz === ical4jRegistry.getTimeZone("Europe/Kiev")) // we have made a copy + Assert.assertEquals("Europe/Kiev", tz?.id) + Assert.assertEquals("Europe/Kiev", tz?.vTimeZone?.timeZoneId?.value) } @Test @@ -73,7 +71,7 @@ class AndroidCompatTimeZoneRegistryTest { /* Unfortunately, AndroidCompatTimeZoneRegistry can't rewrite to Europy/Kyiv to anything because it doesn't know a valid Android name for it. */ - assertEquals( + Assert.assertEquals( ical4jRegistry.getTimeZone("Europe/Kyiv"), registry.getTimeZone("Europe/Kyiv") ) @@ -82,13 +80,13 @@ class AndroidCompatTimeZoneRegistryTest { @Test fun getTimeZone_Copenhagen_NoBerlin() { val tz = registry.getTimeZone("Europe/Copenhagen")!! - assertEquals("Europe/Copenhagen", tz.id) - assertFalse(tz.vTimeZone.toString().contains("Berlin")) + Assert.assertEquals("Europe/Copenhagen", tz.id) + Assert.assertFalse(tz.vTimeZone.toString().contains("Berlin")) } @Test fun getTimeZone_NotExisting() { - assertNull(registry.getTimeZone("Test/NotExisting")) + Assert.assertNull(registry.getTimeZone("Test/NotExisting")) } } \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/synctools/icalendar/ICalendarParserTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/icalendar/ICalendarParserTest.kt index c4df7d84..86971ed5 100644 --- a/lib/src/test/kotlin/at/bitfire/synctools/icalendar/ICalendarParserTest.kt +++ b/lib/src/test/kotlin/at/bitfire/synctools/icalendar/ICalendarParserTest.kt @@ -6,8 +6,8 @@ package at.bitfire.synctools.icalendar -import at.bitfire.ical4android.validation.ICalPreprocessor import at.bitfire.synctools.exception.InvalidRemoteResourceException +import at.bitfire.synctools.icalendar.validation.ICalPreprocessor import io.mockk.junit4.MockKRule import io.mockk.mockkObject import io.mockk.verify diff --git a/lib/src/test/kotlin/at/bitfire/ical4android/validation/FixInvalidDayOffsetPreprocessorTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/icalendar/validation/FixInvalidDayOffsetPreprocessorTest.kt similarity index 98% rename from lib/src/test/kotlin/at/bitfire/ical4android/validation/FixInvalidDayOffsetPreprocessorTest.kt rename to lib/src/test/kotlin/at/bitfire/synctools/icalendar/validation/FixInvalidDayOffsetPreprocessorTest.kt index 0293902a..30349795 100644 --- a/lib/src/test/kotlin/at/bitfire/ical4android/validation/FixInvalidDayOffsetPreprocessorTest.kt +++ b/lib/src/test/kotlin/at/bitfire/synctools/icalendar/validation/FixInvalidDayOffsetPreprocessorTest.kt @@ -4,7 +4,7 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -package at.bitfire.ical4android.validation +package at.bitfire.synctools.icalendar.validation import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse diff --git a/lib/src/test/kotlin/at/bitfire/ical4android/validation/FixInvalidUtcOffsetPreprocessorTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/icalendar/validation/FixInvalidUtcOffsetPreprocessorTest.kt similarity index 97% rename from lib/src/test/kotlin/at/bitfire/ical4android/validation/FixInvalidUtcOffsetPreprocessorTest.kt rename to lib/src/test/kotlin/at/bitfire/synctools/icalendar/validation/FixInvalidUtcOffsetPreprocessorTest.kt index f280b479..3a649d69 100644 --- a/lib/src/test/kotlin/at/bitfire/ical4android/validation/FixInvalidUtcOffsetPreprocessorTest.kt +++ b/lib/src/test/kotlin/at/bitfire/synctools/icalendar/validation/FixInvalidUtcOffsetPreprocessorTest.kt @@ -4,7 +4,7 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -package at.bitfire.ical4android.validation +package at.bitfire.synctools.icalendar.validation import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse